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.
This commit is contained in:
parent
91c9d98a99
commit
14b90ee9f3
24 changed files with 636 additions and 124 deletions
45
bugs.md
45
bugs.md
|
|
@ -1,27 +1,28 @@
|
||||||
# Identified Bugs and Observations (Post-Fix Review)
|
# Bugs Found 2026-05-11
|
||||||
|
|
||||||
This document reflects the state of the codebase after the fixes for the initial 15 bugs were applied.
|
The first batch (7 items: pending-beacon access control, per_page contract,
|
||||||
|
rate-limiter ETS lifecycle, unbounded grid endpoints, NEXRAD PNG quadratic
|
||||||
|
unfilter, LiveTableFooter parser, ScoresFile parser) was fixed and removed
|
||||||
|
from this file as each was resolved. The items below are additional bugs
|
||||||
|
surfaced by a follow-up audit.
|
||||||
|
|
||||||
All three items below were verified, covered by failing tests, and fixed on 2026-04-25.
|
## A. `String.to_integer` on user-controlled `band` param crashes LiveViews
|
||||||
|
|
||||||
## 1. Haversine Implementation Inconsistency — FIXED 2026-04-25
|
**Status:** Fixed.
|
||||||
**Files:** `lib/microwaveprop/radio.ex` vs `lib/microwaveprop/commercial.ex`
|
**Severity:** High
|
||||||
**Description:**
|
**Category:** Availability / unhandled crash on user input
|
||||||
- `Radio.haversine_km/4` uses the `asin` form: `2 * r * :math.asin(:math.sqrt(a))`
|
**Files:**
|
||||||
- `Commercial.haversine_km/4` uses the `atan2` form: `2 * r * :math.atan2(:math.sqrt(a), :math.sqrt(1 - a))`
|
- `lib/microwaveprop_web/live/path_live.ex` (formerly lines 227, 284)
|
||||||
**Impact:** While functionally similar, `atan2` is the industry standard for floating-point stability at large distances. Having two different implementations for the same physical calculation can lead to tiny discrepancies (sub-millimeter) that might cause a contact or commercial link to intermittently cross a distance threshold (e.g., 75 km) depending on which module performs the check.
|
- `lib/microwaveprop_web/live/map_live.ex` (formerly line 198)
|
||||||
**Fix:** Switched `Radio.haversine_km/4` to the `atan2` form and made `Commercial`'s private wrapper delegate to it. One canonical implementation, no per-module drift. Numerical-stability regression test added in `test/microwaveprop/radio_test.exs`.
|
|
||||||
|
|
||||||
## 2. Grid Snapping Mathematical Drift — FIXED 2026-04-25
|
`PathLive.handle_event("calculate", …)` and the auto-calculate path both
|
||||||
**Files:** `lib/microwaveprop/propagation/profiles_file.ex` vs `rust/prop_grid_rs/src/profiles_file.rs`
|
called `String.to_integer(params["band"])` directly on the `band` form
|
||||||
**Description:**
|
parameter. `MapLive.handle_event("select_band", %{"value" => band}, …)`
|
||||||
- Elixir `snap/2`: `Float.round(Float.round(lat / step) * step, 3)`
|
did the same. A non-numeric `band` value — supplied via a hand-crafted
|
||||||
- Rust `snap_coords`: `(lat * 1000.0).round() / 1000.0`
|
URL (`/path?band=abc`) or a misbehaving JS hook — raised `ArgumentError`
|
||||||
**Impact:** The Elixir version performs a division and multiplication by the grid step (`0.125`) before rounding. The Rust version rounds the raw coordinate to the third decimal place. For coordinates that land exactly on a $0.0625$ boundary (the halfway point between cells), these two methods may snap to different cells, causing the UI to report "No Data" or show the wrong factor breakdown for a clicked point.
|
inside the LiveView process and crashed the channel.
|
||||||
**Fix:** Rust `snap_coords` now mirrors the Elixir step-aware snap (`round(coord / 0.125) * 0.125`, then 3-decimal round). Cross-stack parity test in `rust/prop_grid_rs/src/profiles_file.rs` enumerates known-disagreeing inputs.
|
|
||||||
|
|
||||||
## 3. Potential Exception in Wgrib2 Parsing (Theoretical) — FIXED 2026-04-25
|
**Fix:** Use the existing `LiveHelpers.parse_int/2` (PathLive) and the
|
||||||
**File:** `lib/microwaveprop/weather/grib2/wgrib2.ex`
|
existing `parse_band_param/1` plus a small `normalize_band_event/1` helper
|
||||||
**Description:** `parse_lon_val_segment/1` uses `String.to_float(lon_str)` for the longitude, but `Float.parse` for latitude and value.
|
(MapLive). Unknown bands now fall back to a safe default or a no-op
|
||||||
**Impact:** If `wgrib2` were to ever return an integer string for longitude (e.g. `"235"`) instead of a float (`"235.0"`), `String.to_float` would raise an `ArgumentError`. While `wgrib2` typically outputs floats, using `Float.parse` would be more consistent with the other fields in the same line.
|
instead of crashing the process.
|
||||||
**Fix:** All three numeric fields now go through `Float.parse/1` (in a `with` chain so a malformed segment returns `nil` instead of bringing down the chain step). Function exposed as `@doc false` and covered by direct unit tests for both decimal-formatted and integer-formatted longitude strings.
|
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,11 @@ defmodule Microwaveprop.Application do
|
||||||
# leader to drop. See `Microwaveprop.Pskr.Client`. Disabled
|
# leader to drop. See `Microwaveprop.Pskr.Client`. Disabled
|
||||||
# in dev/test so `mix test` doesn't try to dial out.
|
# in dev/test so `mix test` doesn't try to dial out.
|
||||||
Microwaveprop.Pskr.Aggregator,
|
Microwaveprop.Pskr.Aggregator,
|
||||||
pskr_client_child_spec()
|
pskr_client_child_spec(),
|
||||||
|
# Periodic GC for the API rate limiter ETS table; without this
|
||||||
|
# the table accumulates one row per distinct caller per window
|
||||||
|
# for the lifetime of the BEAM.
|
||||||
|
MicrowavepropWeb.Api.RateLimiter.Sweeper
|
||||||
]
|
]
|
||||||
|
|
||||||
# See https://hexdocs.pm/elixir/Supervisor.html
|
# See https://hexdocs.pm/elixir/Supervisor.html
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,33 @@ defmodule Microwaveprop.Beacons do
|
||||||
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() | [Beacon.t()] | nil
|
@spec get_beacon!(Ecto.UUID.t()) :: Beacon.t() | [Beacon.t()] | nil
|
||||||
def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)
|
def get_beacon!(id), do: Beacon |> Repo.get!(id) |> Repo.preload(:user)
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a beacon only if the viewer is allowed to see it. Approved
|
||||||
|
beacons are visible to everyone; pending (unapproved) beacons are
|
||||||
|
visible only to the submitter and admins. Returns `nil` for
|
||||||
|
not-found, malformed ids, and unauthorized viewers — callers render
|
||||||
|
the same 404 in each case so existence of a pending beacon is not
|
||||||
|
observable.
|
||||||
|
"""
|
||||||
|
@spec get_visible_beacon(Ecto.UUID.t() | String.t(), User.t() | nil) :: Beacon.t() | nil
|
||||||
|
def get_visible_beacon(id, viewer) do
|
||||||
|
case fetch_beacon(id) do
|
||||||
|
nil -> nil
|
||||||
|
beacon -> if can_view?(beacon, viewer), do: beacon
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fetch_beacon(id) do
|
||||||
|
Beacon |> Repo.get(id) |> Repo.preload(:user)
|
||||||
|
rescue
|
||||||
|
Ecto.Query.CastError -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
defp can_view?(%Beacon{approved: true}, _viewer), do: true
|
||||||
|
defp can_view?(%Beacon{}, %User{is_admin: true}), do: true
|
||||||
|
defp can_view?(%Beacon{user_id: uid}, %User{id: uid}) when not is_nil(uid), do: true
|
||||||
|
defp can_view?(%Beacon{}, _viewer), do: false
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Creates a beacon. When a user is provided they are recorded as the
|
Creates a beacon. When a user is provided they are recorded as the
|
||||||
creator; anonymous submissions pass `nil` and leave `user_id` unset.
|
creator; anonymous submissions pass `nil` and leave `user_id` unset.
|
||||||
|
|
|
||||||
|
|
@ -480,9 +480,24 @@ defmodule Microwaveprop.Propagation.ScoresFile do
|
||||||
|
|
||||||
defp fetch_bound(bounds, atom_key, string_key, default) do
|
defp fetch_bound(bounds, atom_key, string_key, default) do
|
||||||
case Map.get(bounds, atom_key) || Map.get(bounds, string_key) do
|
case Map.get(bounds, atom_key) || Map.get(bounds, string_key) do
|
||||||
nil -> default
|
nil ->
|
||||||
v when is_number(v) -> v
|
default
|
||||||
v when is_binary(v) -> String.to_float(v)
|
|
||||||
|
v when is_number(v) ->
|
||||||
|
v
|
||||||
|
|
||||||
|
v when is_binary(v) ->
|
||||||
|
case Float.parse(v) do
|
||||||
|
{f, ""} -> f
|
||||||
|
# `Float.parse` accepts trailing junk (`"1.5xyz"` → `{1.5, "xyz"}`);
|
||||||
|
# only treat values that fully consume the input as parsed. On
|
||||||
|
# any other shape, fall back to the caller-supplied default
|
||||||
|
# rather than raising and crashing the caller.
|
||||||
|
_ -> default
|
||||||
|
end
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
default
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ defmodule Microwaveprop.Radio do
|
||||||
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
||||||
|
|
||||||
@per_page 20
|
@per_page 20
|
||||||
|
@max_per_page 200
|
||||||
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
|
||||||
@count_cache_key {__MODULE__, :total_contact_count}
|
@count_cache_key {__MODULE__, :total_contact_count}
|
||||||
@count_cache_ttl_ms 30_000
|
@count_cache_ttl_ms 30_000
|
||||||
|
|
@ -152,7 +153,8 @@ defmodule Microwaveprop.Radio do
|
||||||
@spec list_contacts(keyword()) :: contact_page()
|
@spec list_contacts(keyword()) :: contact_page()
|
||||||
def list_contacts(opts \\ []) do
|
def list_contacts(opts \\ []) do
|
||||||
page = max(Keyword.get(opts, :page, 1), 1)
|
page = max(Keyword.get(opts, :page, 1), 1)
|
||||||
offset = (page - 1) * @per_page
|
per_page = clamp_per_page(Keyword.get(opts, :per_page, @per_page))
|
||||||
|
offset = (page - 1) * per_page
|
||||||
search = Keyword.get(opts, :search)
|
search = Keyword.get(opts, :search)
|
||||||
scope = Keyword.get(opts, :scope)
|
scope = Keyword.get(opts, :scope)
|
||||||
|
|
||||||
|
|
@ -164,12 +166,12 @@ defmodule Microwaveprop.Radio do
|
||||||
|> filter_private(scope)
|
|> filter_private(scope)
|
||||||
|
|
||||||
total_entries = total_entries_for(search, scope, base_query)
|
total_entries = total_entries_for(search, scope, base_query)
|
||||||
total_pages = max(ceil(total_entries / @per_page), 1)
|
total_pages = max(ceil(total_entries / per_page), 1)
|
||||||
|
|
||||||
entries =
|
entries =
|
||||||
base_query
|
base_query
|
||||||
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}, {^sort_dir, q.id}])
|
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}, {^sort_dir, q.id}])
|
||||||
|> limit(^@per_page)
|
|> limit(^per_page)
|
||||||
|> offset(^offset)
|
|> offset(^offset)
|
||||||
|> Repo.all()
|
|> Repo.all()
|
||||||
|> Repo.preload(:user)
|
|> Repo.preload(:user)
|
||||||
|
|
@ -183,6 +185,10 @@ defmodule Microwaveprop.Radio do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp clamp_per_page(value) when is_integer(value) and value >= 1, do: min(value, @max_per_page)
|
||||||
|
|
||||||
|
defp clamp_per_page(_), do: @per_page
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
|
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
|
||||||
Returns a list of `{primary, reciprocals}` tuples where reciprocals is
|
Returns a list of `{primary, reciprocals}` tuples where reciprocals is
|
||||||
|
|
|
||||||
|
|
@ -388,59 +388,66 @@ defmodule Microwaveprop.Weather.NexradClient do
|
||||||
unfilter_paeth(row, prev, bpp)
|
unfilter_paeth(row, prev, bpp)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp unfilter_sub(row, bpp) do
|
# Indexed-color PNG only ever has bpp = 1 (one palette index byte per
|
||||||
row_bytes = :binary.bin_to_list(row)
|
# pixel), so "left" is simply the previously-decoded output byte. We
|
||||||
|
# walk the input binary once and accumulate output bytes into an
|
||||||
|
# iolist, which is O(n) — the original `acc ++ [byte]` plus
|
||||||
|
# `Enum.at(acc, idx - bpp)` was O(n²) per row and dominated decode
|
||||||
|
# time for the 12200×5400 n0q frame.
|
||||||
|
defp unfilter_sub(row, 1), do: unfilter_sub_iter(row, 0, [])
|
||||||
|
|
||||||
{result, _} =
|
defp unfilter_sub_iter(<<>>, _left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
|
||||||
Enum.reduce(row_bytes, {[], 0}, fn byte, {acc, idx} ->
|
|
||||||
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
|
|
||||||
val = rem(byte + left, 256)
|
|
||||||
{acc ++ [val], idx + 1}
|
|
||||||
end)
|
|
||||||
|
|
||||||
:binary.list_to_bin(result)
|
defp unfilter_sub_iter(<<byte, rest::binary>>, left, acc) do
|
||||||
|
val = rem(byte + left, 256)
|
||||||
|
unfilter_sub_iter(rest, val, [val | acc])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp unfilter_up(row, prev) do
|
defp unfilter_up(row, prev), do: unfilter_up_iter(row, prev, [])
|
||||||
row_bytes = :binary.bin_to_list(row)
|
|
||||||
prev_bytes = :binary.bin_to_list(prev)
|
|
||||||
|
|
||||||
row_bytes
|
defp unfilter_up_iter(<<>>, _prev, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
|
||||||
|> Enum.zip(prev_bytes)
|
|
||||||
|> Enum.map(fn {a, b} -> rem(a + b, 256) end)
|
defp unfilter_up_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, acc) do
|
||||||
|> :binary.list_to_bin()
|
val = rem(byte + above, 256)
|
||||||
|
unfilter_up_iter(row_rest, prev_rest, [val | acc])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp unfilter_average(row, prev, bpp) do
|
# `prev` may be shorter than `row` if a row is shorter than the
|
||||||
row_bytes = :binary.bin_to_list(row)
|
# previous one — for indexed-color this never happens but we
|
||||||
prev_bytes = :binary.bin_to_list(prev)
|
# defensively treat missing prior bytes as zero.
|
||||||
|
defp unfilter_up_iter(<<byte, row_rest::binary>>, <<>>, acc) do
|
||||||
{result, _} =
|
val = rem(byte, 256)
|
||||||
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
|
unfilter_up_iter(row_rest, <<>>, [val | acc])
|
||||||
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
|
|
||||||
above = Enum.at(prev_list, idx, 0)
|
|
||||||
val = rem(byte + div(left + above, 2), 256)
|
|
||||||
{acc ++ [val], prev_list}
|
|
||||||
end)
|
|
||||||
|
|
||||||
:binary.list_to_bin(result)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
defp unfilter_paeth(row, prev, bpp) do
|
defp unfilter_average(row, prev, 1), do: unfilter_average_iter(row, prev, 0, [])
|
||||||
row_bytes = :binary.bin_to_list(row)
|
|
||||||
prev_bytes = :binary.bin_to_list(prev)
|
|
||||||
|
|
||||||
{result, _} =
|
defp unfilter_average_iter(<<>>, _prev, _left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
|
||||||
Enum.reduce(Enum.with_index(row_bytes), {[], prev_bytes}, fn {byte, idx}, {acc, prev_list} ->
|
|
||||||
left = if idx >= bpp, do: Enum.at(acc, idx - bpp), else: 0
|
|
||||||
above = Enum.at(prev_list, idx, 0)
|
|
||||||
upper_left = if idx >= bpp, do: Enum.at(prev_list, idx - bpp, 0), else: 0
|
|
||||||
pr = paeth_predictor(left, above, upper_left)
|
|
||||||
val = rem(byte + pr, 256)
|
|
||||||
{acc ++ [val], prev_list}
|
|
||||||
end)
|
|
||||||
|
|
||||||
:binary.list_to_bin(result)
|
defp unfilter_average_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, left, acc) do
|
||||||
|
val = rem(byte + div(left + above, 2), 256)
|
||||||
|
unfilter_average_iter(row_rest, prev_rest, val, [val | acc])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unfilter_average_iter(<<byte, row_rest::binary>>, <<>>, left, acc) do
|
||||||
|
val = rem(byte + div(left, 2), 256)
|
||||||
|
unfilter_average_iter(row_rest, <<>>, val, [val | acc])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unfilter_paeth(row, prev, 1), do: unfilter_paeth_iter(row, prev, 0, 0, [])
|
||||||
|
|
||||||
|
defp unfilter_paeth_iter(<<>>, _prev, _left, _upper_left, acc), do: acc |> Enum.reverse() |> :binary.list_to_bin()
|
||||||
|
|
||||||
|
defp unfilter_paeth_iter(<<byte, row_rest::binary>>, <<above, prev_rest::binary>>, left, upper_left, acc) do
|
||||||
|
pr = paeth_predictor(left, above, upper_left)
|
||||||
|
val = rem(byte + pr, 256)
|
||||||
|
unfilter_paeth_iter(row_rest, prev_rest, val, above, [val | acc])
|
||||||
|
end
|
||||||
|
|
||||||
|
defp unfilter_paeth_iter(<<byte, row_rest::binary>>, <<>>, left, _upper_left, acc) do
|
||||||
|
pr = paeth_predictor(left, 0, 0)
|
||||||
|
val = rem(byte + pr, 256)
|
||||||
|
unfilter_paeth_iter(row_rest, <<>>, val, 0, [val | acc])
|
||||||
end
|
end
|
||||||
|
|
||||||
defp paeth_predictor(a, b, c) do
|
defp paeth_predictor(a, b, c) do
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Tiny ETS-backed fixed-window rate limiter for `/api/v1`.
|
Tiny ETS-backed fixed-window rate limiter for `/api/v1`.
|
||||||
|
|
||||||
No external dependency, no supervisor entry — the named ETS table
|
The named ETS table is created at application boot (and re-created
|
||||||
is created lazily on the first request and survives for the
|
idempotently if the boot path is bypassed in tests). Each
|
||||||
lifetime of the BEAM. Each (bucket, window) pair holds a counter
|
`{bucket, window}` pair holds a counter incremented atomically via
|
||||||
incremented atomically via `:ets.update_counter/4`.
|
`:ets.update_counter/4`.
|
||||||
|
|
||||||
Default limits (overridable per plug invocation):
|
Default limits (overridable per plug invocation):
|
||||||
* authenticated requests: 600 / minute, keyed by API token id
|
* authenticated requests: 600 / minute, keyed by API token id
|
||||||
|
|
@ -13,6 +13,14 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
|
||||||
|
|
||||||
The plug emits the RFC 9651 `RateLimit` headers and a 429
|
The plug emits the RFC 9651 `RateLimit` headers and a 429
|
||||||
problem+json response when the bucket is exhausted.
|
problem+json response when the bucket is exhausted.
|
||||||
|
|
||||||
|
## Lifecycle
|
||||||
|
|
||||||
|
Without a sweeper the table grows one row per distinct `{bucket,
|
||||||
|
window}` and never shrinks — every distinct IP / token over the
|
||||||
|
pod's lifetime stays resident. The companion `Sweeper` GenServer
|
||||||
|
prunes rows whose window has fully expired on a fixed cadence; see
|
||||||
|
`MicrowavepropWeb.Api.RateLimiter.Sweeper`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@behaviour Plug
|
@behaviour Plug
|
||||||
|
|
@ -26,15 +34,28 @@ defmodule MicrowavepropWeb.Api.RateLimiter do
|
||||||
@default_anon_limit 60
|
@default_anon_limit 60
|
||||||
@default_auth_limit 600
|
@default_auth_limit 600
|
||||||
|
|
||||||
|
@doc "Returns the ETS table name backing the limiter."
|
||||||
|
@spec table() :: atom()
|
||||||
|
def table, do: @table
|
||||||
|
|
||||||
|
@doc "Returns the default window size in milliseconds."
|
||||||
|
@spec default_window_ms() :: pos_integer()
|
||||||
|
def default_window_ms, do: @default_window_ms
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Initializes the named ETS table. Idempotent — safe to call from
|
Initializes the named ETS table. Idempotent — safe to call from
|
||||||
application boot and from tests. Returns `:ok` whether the table
|
application boot and from tests. The race between `whereis` and
|
||||||
existed already or was created by this call.
|
`:ets.new` is handled by rescuing `ArgumentError`, which is what
|
||||||
|
`:ets.new` raises when the table already exists.
|
||||||
"""
|
"""
|
||||||
@spec init_table() :: :ok
|
@spec init_table() :: :ok
|
||||||
def init_table do
|
def init_table do
|
||||||
if :ets.whereis(@table) == :undefined do
|
if :ets.whereis(@table) == :undefined do
|
||||||
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
|
try do
|
||||||
|
:ets.new(@table, [:set, :public, :named_table, write_concurrency: true])
|
||||||
|
rescue
|
||||||
|
ArgumentError -> :ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
:ok
|
:ok
|
||||||
|
|
|
||||||
103
lib/microwaveprop_web/api/rate_limiter/sweeper.ex
Normal file
103
lib/microwaveprop_web/api/rate_limiter/sweeper.ex
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
defmodule MicrowavepropWeb.Api.RateLimiter.Sweeper do
|
||||||
|
@moduledoc """
|
||||||
|
Periodic GC for the API rate limiter ETS table.
|
||||||
|
|
||||||
|
The limiter writes one row per `{bucket, window}` and never
|
||||||
|
overwrites — without a sweeper the table accumulates one row per
|
||||||
|
distinct caller per window for the lifetime of the BEAM. This
|
||||||
|
process scans the table on a fixed cadence and deletes any row
|
||||||
|
whose window has fully expired (window index strictly less than
|
||||||
|
the current window index).
|
||||||
|
|
||||||
|
Defaults to one sweep per 5 minutes. Configure via:
|
||||||
|
|
||||||
|
config :microwaveprop, MicrowavepropWeb.Api.RateLimiter.Sweeper,
|
||||||
|
interval_ms: 300_000
|
||||||
|
|
||||||
|
Test helper `sweep_now/1` runs a sweep synchronously.
|
||||||
|
"""
|
||||||
|
|
||||||
|
use GenServer
|
||||||
|
|
||||||
|
alias MicrowavepropWeb.Api.RateLimiter
|
||||||
|
|
||||||
|
@default_interval_ms 5 * 60 * 1_000
|
||||||
|
|
||||||
|
@spec start_link(keyword()) :: GenServer.on_start()
|
||||||
|
def start_link(opts \\ []) do
|
||||||
|
name = Keyword.get(opts, :name, __MODULE__)
|
||||||
|
GenServer.start_link(__MODULE__, opts, name: name)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Run a sweep now. Returns the number of rows deleted."
|
||||||
|
@spec sweep_now(GenServer.server()) :: non_neg_integer()
|
||||||
|
def sweep_now(server \\ __MODULE__) do
|
||||||
|
GenServer.call(server, :sweep_now)
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def init(opts) do
|
||||||
|
interval_ms =
|
||||||
|
opts
|
||||||
|
|> Keyword.get(:interval_ms)
|
||||||
|
|> Kernel.||(
|
||||||
|
:microwaveprop
|
||||||
|
|> Application.get_env(__MODULE__, [])
|
||||||
|
|> Keyword.get(:interval_ms, @default_interval_ms)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Own the ETS table from this long-lived process. ETS named tables
|
||||||
|
# die with their owner — having a transient request task or
|
||||||
|
# short-lived test process create the table leaves the system
|
||||||
|
# without a backing store the moment that process exits. The
|
||||||
|
# application boot path also calls `RateLimiter.init_table/0` for
|
||||||
|
# the cold-start safety net; both paths are idempotent.
|
||||||
|
:ok = RateLimiter.init_table()
|
||||||
|
|
||||||
|
schedule(interval_ms)
|
||||||
|
{:ok, %{interval_ms: interval_ms}}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info(:sweep, state) do
|
||||||
|
_ = sweep()
|
||||||
|
schedule(state.interval_ms)
|
||||||
|
{:noreply, state}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_call(:sweep_now, _from, state) do
|
||||||
|
{:reply, sweep(), state}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp schedule(interval_ms) do
|
||||||
|
Process.send_after(self(), :sweep, interval_ms)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sweep do
|
||||||
|
table = RateLimiter.table()
|
||||||
|
|
||||||
|
# Defensive: if the table is missing (test that hasn't booted the
|
||||||
|
# app, etc.) there is nothing to prune.
|
||||||
|
if :ets.whereis(table) == :undefined do
|
||||||
|
0
|
||||||
|
else
|
||||||
|
window_ms = RateLimiter.default_window_ms()
|
||||||
|
current_window = div(System.system_time(:millisecond), window_ms)
|
||||||
|
|
||||||
|
# Match rows whose window index is strictly less than the
|
||||||
|
# current window — those windows can no longer be hit (even a
|
||||||
|
# request arriving exactly at the boundary uses the new
|
||||||
|
# window). `:ets.select_delete/2` returns the count.
|
||||||
|
match_spec = [
|
||||||
|
{
|
||||||
|
{{:_, :"$1"}, :_},
|
||||||
|
[{:<, :"$1", current_window}],
|
||||||
|
[true]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
:ets.select_delete(table, match_spec)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -16,7 +16,9 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
|
||||||
end
|
end
|
||||||
|
|
||||||
def show(conn, %{"id" => id}) do
|
def show(conn, %{"id" => id}) do
|
||||||
case fetch_beacon(id) do
|
viewer = conn.assigns[:current_api_user]
|
||||||
|
|
||||||
|
case Beacons.get_visible_beacon(id, viewer) do
|
||||||
nil -> ErrorJSON.send_problem(conn, 404, "not_found", "Beacon not found.")
|
nil -> ErrorJSON.send_problem(conn, 404, "not_found", "Beacon not found.")
|
||||||
beacon -> json(conn, BeaconJSON.show(%{beacon: beacon}))
|
beacon -> json(conn, BeaconJSON.show(%{beacon: beacon}))
|
||||||
end
|
end
|
||||||
|
|
@ -41,12 +43,4 @@ defmodule MicrowavepropWeb.Api.V1.BeaconController do
|
||||||
ErrorJSON.send_changeset(conn, changeset)
|
ErrorJSON.send_changeset(conn, changeset)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# `Beacons.get_beacon!/1` raises on bad ids; we want a clean 404.
|
|
||||||
defp fetch_beacon(id) do
|
|
||||||
Beacons.get_beacon!(id)
|
|
||||||
rescue
|
|
||||||
Ecto.NoResultsError -> nil
|
|
||||||
Ecto.Query.CastError -> nil
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ defmodule MicrowavepropWeb.Api.V1.ContactController do
|
||||||
%{entries: entries, total_entries: total, total_pages: total_pages} =
|
%{entries: entries, total_entries: total, total_pages: total_pages} =
|
||||||
Radio.list_contacts(
|
Radio.list_contacts(
|
||||||
page: page,
|
page: page,
|
||||||
|
per_page: per_page,
|
||||||
search: search,
|
search: search,
|
||||||
scope: Scope.for_user(viewer)
|
scope: Scope.for_user(viewer)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,16 +5,25 @@ defmodule MicrowavepropWeb.ScoresController do
|
||||||
the /map overlay so the client can fetch directly (cacheable, doesn't
|
the /map overlay so the client can fetch directly (cacheable, doesn't
|
||||||
block the LiveView channel) and decode via DataView + typed-array
|
block the LiveView channel) and decode via DataView + typed-array
|
||||||
views without a JSON parse.
|
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
|
use MicrowavepropWeb, :controller
|
||||||
|
|
||||||
alias Microwaveprop.Propagation
|
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()
|
@spec cells(Plug.Conn.t(), map()) :: Plug.Conn.t()
|
||||||
def cells(conn, %{"band" => band_param} = params) do
|
def cells(conn, %{"band" => band_param} = params) do
|
||||||
with {band_mhz, ""} <- Integer.parse(band_param),
|
with {band_mhz, ""} <- Integer.parse(band_param),
|
||||||
{:ok, valid_time} <- parse_optional_time(params["time"]),
|
{:ok, valid_time} <- parse_optional_time(params["time"]),
|
||||||
{:ok, bounds} <- parse_bounds(params) do
|
{:ok, bounds} <- parse_bounds(params),
|
||||||
|
{:ok, bounds} <- GridBounds.clamp(bounds) do
|
||||||
scores =
|
scores =
|
||||||
if valid_time do
|
if valid_time do
|
||||||
Propagation.scores_at(band_mhz, valid_time, bounds)
|
Propagation.scores_at(band_mhz, valid_time, bounds)
|
||||||
|
|
@ -27,6 +36,7 @@ defmodule MicrowavepropWeb.ScoresController do
|
||||||
|> put_resp_header("cache-control", "public, max-age=60")
|
|> put_resp_header("cache-control", "public, max-age=60")
|
||||||
|> send_resp(200, encode_binary(scores))
|
|> send_resp(200, encode_binary(scores))
|
||||||
else
|
else
|
||||||
|
{:error, :viewport_too_large} -> payload_too_large(conn)
|
||||||
_ -> bad_request(conn)
|
_ -> bad_request(conn)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -39,6 +49,12 @@ defmodule MicrowavepropWeb.ScoresController do
|
||||||
|> json(%{error: "invalid params"})
|
|> json(%{error: "invalid params"})
|
||||||
end
|
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(nil), do: {:ok, nil}
|
||||||
|
|
||||||
defp parse_optional_time(value) when is_binary(value) do
|
defp parse_optional_time(value) when is_binary(value) do
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,10 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
use MicrowavepropWeb, :controller
|
use MicrowavepropWeb, :controller
|
||||||
|
|
||||||
alias Microwaveprop.Weather
|
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
|
# Layer fields the binary cell-pack can carry. Used both as the
|
||||||
# whitelist for ?layers=... and as the default if the param is absent.
|
# whitelist for ?layers=... and as the default if the param is absent.
|
||||||
|
|
@ -17,6 +21,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
def cells(conn, %{"time" => time_param} = params) do
|
def cells(conn, %{"time" => time_param} = params) do
|
||||||
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
with {:ok, valid_time, _offset} <- DateTime.from_iso8601(time_param),
|
||||||
{:ok, bounds} <- parse_bounds(params),
|
{:ok, bounds} <- parse_bounds(params),
|
||||||
|
{:ok, bounds} <- GridBounds.clamp(bounds),
|
||||||
{:ok, layers} <- parse_layers(params["layers"]) do
|
{:ok, layers} <- parse_layers(params["layers"]) do
|
||||||
rows = read_rows(valid_time, bounds, params["source"])
|
rows = read_rows(valid_time, bounds, params["source"])
|
||||||
|
|
||||||
|
|
@ -25,6 +30,7 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
|> put_resp_header("cache-control", "public, max-age=60")
|
|> put_resp_header("cache-control", "public, max-age=60")
|
||||||
|> send_resp(200, encode_binary(rows, layers))
|
|> send_resp(200, encode_binary(rows, layers))
|
||||||
else
|
else
|
||||||
|
{:error, :viewport_too_large} -> payload_too_large(conn)
|
||||||
_ -> bad_request(conn)
|
_ -> bad_request(conn)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -40,6 +46,12 @@ defmodule MicrowavepropWeb.WeatherTileController do
|
||||||
|> json(%{error: "invalid params"})
|
|> json(%{error: "invalid params"})
|
||||||
end
|
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
|
defp parse_bounds(%{"south" => s, "north" => n, "west" => w, "east" => e}) do
|
||||||
with {:ok, south} <- parse_float(s),
|
with {:ok, south} <- parse_float(s),
|
||||||
{:ok, north} <- parse_float(n),
|
{:ok, north} <- parse_float(n),
|
||||||
|
|
|
||||||
73
lib/microwaveprop_web/grid_bounds.ex
Normal file
73
lib/microwaveprop_web/grid_bounds.ex
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MicrowavepropWeb.GridBounds do
|
||||||
|
@moduledoc """
|
||||||
|
Bounds clamp + sanity checks for grid-cell HTTP endpoints
|
||||||
|
(`/scores/cells`, `/weather/cells`).
|
||||||
|
|
||||||
|
The propagation grid covers HRRR (CONUS) and HRDPS (Canada to
|
||||||
|
60°N): roughly lat 25-60, lon -141 to -52 — about 35° × 90° =
|
||||||
|
3150 sq degrees. Any caller-provided viewport is clamped to a
|
||||||
|
generous superset of that bbox, and viewports whose area exceeds
|
||||||
|
the supported region are rejected as `:viewport_too_large` so a
|
||||||
|
malicious caller cannot trigger global / multi-million-cell
|
||||||
|
binary responses.
|
||||||
|
|
||||||
|
`nil` bounds are passed through (caller intends the full grid).
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Generous superset of HRRR + HRDPS — a few extra degrees on each
|
||||||
|
# edge so legitimate clients zoomed slightly past the data extent
|
||||||
|
# still get a 200 with the actual covered cells.
|
||||||
|
@lat_min 20.0
|
||||||
|
@lat_max 65.0
|
||||||
|
@lon_min -150.0
|
||||||
|
@lon_max -50.0
|
||||||
|
|
||||||
|
# Comfortably above the HRDPS+HRRR union (~3150 sq deg), but tight
|
||||||
|
# enough that the fully-clamped extent (~4500 sq deg) still trips
|
||||||
|
# the cap — a "give me everything" request is exactly the DoS
|
||||||
|
# shape we want to reject.
|
||||||
|
@max_viewport_area_sq_deg 4000.0
|
||||||
|
|
||||||
|
@type bounds :: %{optional(String.t()) => float()} | nil
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Clamp `bounds` to the supported grid extent. Returns:
|
||||||
|
|
||||||
|
* `{:ok, nil}` when bounds are nil (caller wants the full grid)
|
||||||
|
* `{:ok, clamped_bounds}` when the clamped viewport area is OK
|
||||||
|
* `{:error, :viewport_too_large}` when the clamped area still
|
||||||
|
exceeds `@max_viewport_area_sq_deg` (catches degenerate
|
||||||
|
requests like global bounds that survive clamping)
|
||||||
|
* `{:error, :invalid_bounds}` when north < south or east < west
|
||||||
|
"""
|
||||||
|
@spec clamp(bounds()) ::
|
||||||
|
{:ok, bounds()} | {:error, :viewport_too_large | :invalid_bounds}
|
||||||
|
def clamp(nil), do: {:ok, nil}
|
||||||
|
|
||||||
|
def clamp(%{"south" => s, "north" => n, "west" => w, "east" => e})
|
||||||
|
when is_number(s) and is_number(n) and is_number(w) and is_number(e) do
|
||||||
|
south = clamp_value(s, @lat_min, @lat_max)
|
||||||
|
north = clamp_value(n, @lat_min, @lat_max)
|
||||||
|
west = clamp_value(w, @lon_min, @lon_max)
|
||||||
|
east = clamp_value(e, @lon_min, @lon_max)
|
||||||
|
|
||||||
|
cond do
|
||||||
|
north < south or east < west ->
|
||||||
|
{:error, :invalid_bounds}
|
||||||
|
|
||||||
|
(north - south) * (east - west) > @max_viewport_area_sq_deg ->
|
||||||
|
{:error, :viewport_too_large}
|
||||||
|
|
||||||
|
true ->
|
||||||
|
{:ok, %{"south" => south, "north" => north, "west" => west, "east" => east}}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def clamp(_), do: {:error, :invalid_bounds}
|
||||||
|
|
||||||
|
@doc "Maximum viewport area in square degrees."
|
||||||
|
@spec max_viewport_area_sq_deg() :: float()
|
||||||
|
def max_viewport_area_sq_deg, do: @max_viewport_area_sq_deg
|
||||||
|
|
||||||
|
defp clamp_value(v, lo, hi), do: v |> max(lo) |> min(hi)
|
||||||
|
end
|
||||||
|
|
@ -159,19 +159,28 @@ defmodule MicrowavepropWeb.BeaconLive.Show do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
def mount(%{"id" => id}, _session, socket) do
|
||||||
_ = if connected?(socket), do: Beacons.subscribe_beacons()
|
viewer = socket.assigns[:current_scope] && socket.assigns.current_scope.user
|
||||||
|
|
||||||
beacon = Beacons.get_beacon!(id)
|
case Beacons.get_visible_beacon(id, viewer) do
|
||||||
|
nil ->
|
||||||
|
# Pending beacons are invisible to everyone except the
|
||||||
|
# submitter and admins. Render the same 404 we'd give for an
|
||||||
|
# unknown id so existence isn't observable.
|
||||||
|
raise Ecto.NoResultsError, queryable: Beacon
|
||||||
|
|
||||||
{:ok,
|
beacon ->
|
||||||
socket
|
_ = if connected?(socket), do: Beacons.subscribe_beacons()
|
||||||
|> assign(:page_title, "Beacon")
|
|
||||||
|> assign(:beacon, beacon)
|
{:ok,
|
||||||
|> assign(:show_coverage, false)
|
socket
|
||||||
|> assign(:coverage_supported, coverage_supported?(beacon))
|
|> assign(:page_title, "Beacon")
|
||||||
# Estimate is lazy — computed on the first toggle-on to avoid
|
|> assign(:beacon, beacon)
|
||||||
# paying the bbox grid cost on every page load.
|
|> assign(:show_coverage, false)
|
||||||
|> assign(:estimate, nil)}
|
|> assign(:coverage_supported, coverage_supported?(beacon))
|
||||||
|
# Estimate is lazy — computed on the first toggle-on to avoid
|
||||||
|
# paying the bbox grid cost on every page load.
|
||||||
|
|> assign(:estimate, nil)}
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Estimated current coverage only makes sense from 5.76 GHz upward,
|
# Estimated current coverage only makes sense from 5.76 GHz upward,
|
||||||
|
|
|
||||||
|
|
@ -195,21 +195,15 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("select_band", %{"value" => band}, socket) do
|
def handle_event("select_band", %{"value" => band}, socket) do
|
||||||
band = if is_binary(band), do: String.to_integer(band), else: band
|
case normalize_band_event(band) do
|
||||||
valid_times = Propagation.available_valid_times(band)
|
nil ->
|
||||||
selected_time = closest_to_now(valid_times)
|
# Reject unknown / unparseable bands rather than crashing the
|
||||||
|
# LiveView with String.to_integer/BandConfig.get failures.
|
||||||
|
{:noreply, socket}
|
||||||
|
|
||||||
socket =
|
band ->
|
||||||
socket
|
do_select_band(socket, band)
|
||||||
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|
end
|
||||||
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|
|
||||||
|> MicrowavepropWeb.LiveStashGuard.stash()
|
|
||||||
# band_mhz tells the client to refetch /scores/cells for the new band.
|
|
||||||
|> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band})
|
|
||||||
|> push_timeline()
|
|
||||||
|> patch_map_url()
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def handle_event("select_time", %{"time" => time_str}, socket) do
|
def handle_event("select_time", %{"time" => time_str}, socket) do
|
||||||
|
|
@ -511,6 +505,25 @@ defmodule MicrowavepropWeb.MapLive do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp normalize_band_event(band) when is_integer(band), do: if(BandConfig.get(band), do: band)
|
||||||
|
defp normalize_band_event(band) when is_binary(band), do: parse_band_param(band)
|
||||||
|
defp normalize_band_event(_), do: nil
|
||||||
|
|
||||||
|
defp do_select_band(socket, band) do
|
||||||
|
valid_times = Propagation.available_valid_times(band)
|
||||||
|
selected_time = closest_to_now(valid_times)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(selected_band: band, valid_times: valid_times, selected_time: selected_time)
|
||||||
|
|> assign(:tracking_now?, tracking_now?(selected_time, valid_times, DateTime.utc_now()))
|
||||||
|
|> MicrowavepropWeb.LiveStashGuard.stash()
|
||||||
|
# band_mhz tells the client to refetch /scores/cells for the new band.
|
||||||
|
|> push_event("update_band_info", %{band_info: band_info(band), band_mhz: band})
|
||||||
|
|> push_timeline()
|
||||||
|
|> patch_map_url()
|
||||||
|
|> then(&{:noreply, &1})
|
||||||
|
end
|
||||||
|
|
||||||
@doc false
|
@doc false
|
||||||
def parse_time_param(nil), do: nil
|
def parse_time_param(nil), do: nil
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
use MicrowavepropWeb, :live_view
|
use MicrowavepropWeb, :live_view
|
||||||
|
|
||||||
import MicrowavepropWeb.Components.SkewTChart
|
import MicrowavepropWeb.Components.SkewTChart
|
||||||
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2]
|
import MicrowavepropWeb.LiveHelpers, only: [parse_float: 2, parse_int: 2]
|
||||||
|
|
||||||
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
|
||||||
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
alias Microwaveprop.Buildings.Loader, as: BuildingsLoader
|
||||||
|
|
@ -224,7 +224,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
|
dst_gain_dbi: parse_float(p["dst_gain_dbi"], 30.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
send(self(), {:compute_path, p["source"], p["destination"], String.to_integer(p["band"]), station_params})
|
send(self(), {:compute_path, p["source"], p["destination"], parse_int(p["band"], 10_000), station_params})
|
||||||
{:noreply, assign(socket, computing: true)}
|
{:noreply, assign(socket, computing: true)}
|
||||||
else
|
else
|
||||||
{:noreply, socket}
|
{:noreply, socket}
|
||||||
|
|
@ -281,7 +281,7 @@ defmodule MicrowavepropWeb.PathLive do
|
||||||
|
|
||||||
send(
|
send(
|
||||||
self(),
|
self(),
|
||||||
{:compute_path, params["source"], params["destination"], String.to_integer(params["band"]), station_params}
|
{:compute_path, params["source"], params["destination"], parse_int(params["band"], 10_000), station_params}
|
||||||
)
|
)
|
||||||
|
|
||||||
url_params =
|
url_params =
|
||||||
|
|
|
||||||
|
|
@ -91,7 +91,14 @@ defmodule MicrowavepropWeb.LiveTableFooter do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_page(page) when is_integer(page), do: page
|
defp parse_page(page) when is_integer(page) and page >= 1, do: page
|
||||||
defp parse_page(page) when is_binary(page), do: String.to_integer(page)
|
|
||||||
|
defp parse_page(page) when is_binary(page) do
|
||||||
|
case Integer.parse(page) do
|
||||||
|
{n, ""} when n >= 1 -> n
|
||||||
|
_ -> 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
defp parse_page(_), do: 1
|
defp parse_page(_), do: 1
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,39 @@ defmodule Microwaveprop.RadioTest do
|
||||||
assert result.page == 2
|
assert result.page == 2
|
||||||
assert result.total_pages == 2
|
assert result.total_pages == 2
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "honors per_page option" do
|
||||||
|
for i <- 1..25 do
|
||||||
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
|
create_contact(%{qso_timestamp: ts})
|
||||||
|
end
|
||||||
|
|
||||||
|
result = Radio.list_contacts(per_page: 50)
|
||||||
|
|
||||||
|
assert length(result.entries) == 25
|
||||||
|
assert result.total_pages == 1
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clamps per_page to the maximum" do
|
||||||
|
for i <- 1..3 do
|
||||||
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
|
create_contact(%{qso_timestamp: ts})
|
||||||
|
end
|
||||||
|
|
||||||
|
# 5000 is well above the 200 cap; should still return all 3 rows.
|
||||||
|
result = Radio.list_contacts(per_page: 5000)
|
||||||
|
assert length(result.entries) == 3
|
||||||
|
end
|
||||||
|
|
||||||
|
test "falls back to default when per_page is invalid" do
|
||||||
|
for i <- 1..25 do
|
||||||
|
ts = DateTime.add(~U[2026-01-01 00:00:00Z], i * 3600, :second)
|
||||||
|
create_contact(%{qso_timestamp: ts})
|
||||||
|
end
|
||||||
|
|
||||||
|
result = Radio.list_contacts(per_page: 0)
|
||||||
|
assert length(result.entries) == 20
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "list_contacts/1 sorting" do
|
describe "list_contacts/1 sorting" do
|
||||||
|
|
|
||||||
|
|
@ -79,4 +79,54 @@ defmodule MicrowavepropWeb.Api.RateLimiterTest do
|
||||||
assert RateLimiter.reset() == :ok
|
assert RateLimiter.reset() == :ok
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "init_table/0" do
|
||||||
|
test "is a no-op when the table already exists" do
|
||||||
|
RateLimiter.init_table()
|
||||||
|
assert RateLimiter.init_table() == :ok
|
||||||
|
end
|
||||||
|
|
||||||
|
test "tolerates many concurrent init_table calls" do
|
||||||
|
# `call/2` invokes `init_table/0` on every request; under heavy
|
||||||
|
# load many requests can fly through the whereis check before
|
||||||
|
# any of them call `:ets.new`. The rescue path makes the loser
|
||||||
|
# of that race return `:ok` instead of crashing. The Sweeper
|
||||||
|
# owns the canonical table from the application supervisor, so
|
||||||
|
# concurrent calls must never leave the system without one.
|
||||||
|
tasks =
|
||||||
|
for _ <- 1..16 do
|
||||||
|
Task.async(fn -> RateLimiter.init_table() end)
|
||||||
|
end
|
||||||
|
|
||||||
|
results = Task.await_many(tasks)
|
||||||
|
assert Enum.all?(results, &(&1 == :ok))
|
||||||
|
assert :ets.whereis(RateLimiter.table()) != :undefined
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Sweeper" do
|
||||||
|
alias MicrowavepropWeb.Api.RateLimiter.Sweeper
|
||||||
|
|
||||||
|
test "sweep_now removes expired window rows but keeps current ones" do
|
||||||
|
RateLimiter.reset()
|
||||||
|
table = RateLimiter.table()
|
||||||
|
|
||||||
|
now = System.system_time(:millisecond)
|
||||||
|
window_ms = RateLimiter.default_window_ms()
|
||||||
|
current = div(now, window_ms)
|
||||||
|
|
||||||
|
:ets.insert(table, {{{:ip, "stale-1"}, current - 5}, 1})
|
||||||
|
:ets.insert(table, {{{:ip, "stale-2"}, current - 1}, 4})
|
||||||
|
:ets.insert(table, {{{:ip, "current"}, current}, 2})
|
||||||
|
|
||||||
|
assert :ets.info(table, :size) == 3
|
||||||
|
|
||||||
|
{:ok, pid} = Sweeper.start_link(name: :rate_limiter_sweeper_test, interval_ms: 60_000_000)
|
||||||
|
deleted = Sweeper.sweep_now(pid)
|
||||||
|
|
||||||
|
assert deleted == 2
|
||||||
|
assert :ets.info(table, :size) == 1
|
||||||
|
assert :ets.lookup(table, {{:ip, "current"}, current}) != []
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,45 @@ defmodule MicrowavepropWeb.Api.V1.BeaconControllerTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "GET /api/v1/beacons/:id" do
|
describe "GET /api/v1/beacons/:id" do
|
||||||
test "returns the beacon when found", %{conn: conn, user: user} do
|
test "returns approved beacons to anonymous viewers", %{conn: conn, user: user} do
|
||||||
{:ok, b} = Beacons.create_beacon(user, @valid)
|
{:ok, b} = Beacons.create_beacon(user, @valid)
|
||||||
|
{:ok, b} = Beacons.approve_beacon(b)
|
||||||
body = conn |> get(~p"/api/v1/beacons/#{b.id}") |> json_response(200)
|
body = conn |> get(~p"/api/v1/beacons/#{b.id}") |> json_response(200)
|
||||||
assert body["data"]["id"] == b.id
|
assert body["data"]["id"] == b.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "404 for anonymous viewers on unapproved beacons", %{conn: conn, user: user} do
|
||||||
|
{:ok, b} = Beacons.create_beacon(user, @valid)
|
||||||
|
conn = get(conn, ~p"/api/v1/beacons/#{b.id}")
|
||||||
|
assert json_response(conn, 404)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "submitter can view their own unapproved beacon", %{conn: conn, user: user, plaintext: pt} do
|
||||||
|
{:ok, b} = Beacons.create_beacon(user, @valid)
|
||||||
|
|
||||||
|
body =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> pt)
|
||||||
|
|> get(~p"/api/v1/beacons/#{b.id}")
|
||||||
|
|> json_response(200)
|
||||||
|
|
||||||
|
assert body["data"]["id"] == b.id
|
||||||
|
assert body["data"]["approved"] == false
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-owner non-admin gets 404 on another user's unapproved beacon", %{conn: conn, user: user} do
|
||||||
|
{:ok, b} = Beacons.create_beacon(user, @valid)
|
||||||
|
other = AccountsFixtures.user_fixture()
|
||||||
|
{:ok, {other_pt, _}} = Accounts.create_api_token(other, %{name: "t"})
|
||||||
|
|
||||||
|
conn =
|
||||||
|
conn
|
||||||
|
|> put_req_header("authorization", "Bearer " <> other_pt)
|
||||||
|
|> get(~p"/api/v1/beacons/#{b.id}")
|
||||||
|
|
||||||
|
assert json_response(conn, 404)
|
||||||
|
end
|
||||||
|
|
||||||
test "404 on unknown id", %{conn: conn} do
|
test "404 on unknown id", %{conn: conn} do
|
||||||
conn = get(conn, ~p"/api/v1/beacons/00000000-0000-0000-0000-000000000000")
|
conn = get(conn, ~p"/api/v1/beacons/00000000-0000-0000-0000-000000000000")
|
||||||
assert json_response(conn, 404)
|
assert json_response(conn, 404)
|
||||||
|
|
|
||||||
|
|
@ -100,5 +100,11 @@ defmodule MicrowavepropWeb.ScoresControllerTest do
|
||||||
conn = get(conn, ~p"/scores/cells?band=10000&time=&south=0&north=1&west=-1&east=0")
|
conn = get(conn, ~p"/scores/cells?band=10000&time=&south=0&north=1&west=-1&east=0")
|
||||||
assert json_response(conn, 400) == %{"error" => "invalid params"}
|
assert json_response(conn, 400) == %{"error" => "invalid params"}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns 413 for global / oversized viewport bounds", %{conn: conn} do
|
||||||
|
conn = get(conn, ~p"/scores/cells?band=10000&south=-90&north=90&west=-180&east=180")
|
||||||
|
body = json_response(conn, 413)
|
||||||
|
assert body["error"] == "viewport_too_large"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -165,5 +165,18 @@ defmodule MicrowavepropWeb.WeatherTileControllerTest do
|
||||||
|
|
||||||
File.rm_rf!(hrdps_dir)
|
File.rm_rf!(hrdps_dir)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "returns 413 for global / oversized viewport bounds", %{conn: conn} do
|
||||||
|
time = DateTime.to_iso8601(~U[2026-04-28 12:00:00Z])
|
||||||
|
|
||||||
|
conn =
|
||||||
|
get(
|
||||||
|
conn,
|
||||||
|
~p"/weather/cells?time=#{time}&south=-90&north=90&west=-180&east=180"
|
||||||
|
)
|
||||||
|
|
||||||
|
body = json_response(conn, 413)
|
||||||
|
assert body["error"] == "viewport_too_large"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
38
test/microwaveprop_web/grid_bounds_test.exs
Normal file
38
test/microwaveprop_web/grid_bounds_test.exs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
defmodule MicrowavepropWeb.GridBoundsTest do
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MicrowavepropWeb.GridBounds
|
||||||
|
|
||||||
|
describe "clamp/1" do
|
||||||
|
test "passes nil through" do
|
||||||
|
assert GridBounds.clamp(nil) == {:ok, nil}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "passes a small viewport through unchanged" do
|
||||||
|
bounds = %{"south" => 30.0, "north" => 35.0, "west" => -100.0, "east" => -90.0}
|
||||||
|
assert {:ok, ^bounds} = GridBounds.clamp(bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "clamps south/north/west/east to the supported extent" do
|
||||||
|
bounds = %{"south" => -50.0, "north" => 90.0, "west" => -200.0, "east" => 50.0}
|
||||||
|
assert {:error, :viewport_too_large} = GridBounds.clamp(bounds)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects global bounds with 413-equivalent error" do
|
||||||
|
global = %{"south" => -90.0, "north" => 90.0, "west" => -180.0, "east" => 180.0}
|
||||||
|
assert GridBounds.clamp(global) == {:error, :viewport_too_large}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects inverted bounds" do
|
||||||
|
inverted = %{"south" => 40.0, "north" => 30.0, "west" => -90.0, "east" => -100.0}
|
||||||
|
assert GridBounds.clamp(inverted) == {:error, :invalid_bounds}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rejects malformed bounds" do
|
||||||
|
assert GridBounds.clamp(%{}) == {:error, :invalid_bounds}
|
||||||
|
|
||||||
|
assert GridBounds.clamp(%{"south" => "x", "north" => 1, "west" => 1, "east" => 1}) ==
|
||||||
|
{:error, :invalid_bounds}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -89,12 +89,39 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|
||||||
assert html =~ "10,368.1"
|
assert html =~ "10,368.1"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows pending badge on unapproved beacons", %{conn: conn} do
|
test "shows pending badge to the submitter on their own unapproved beacon", %{conn: conn} do
|
||||||
beacon = beacon_fixture(user_fixture())
|
user = user_fixture()
|
||||||
|
beacon = beacon_fixture(user)
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
|
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
|
||||||
assert html =~ "Pending approval"
|
assert html =~ "Pending approval"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
test "anonymous viewer gets 404 on unapproved beacon", %{conn: conn} do
|
||||||
|
beacon = beacon_fixture(user_fixture(), callsign: "W5HID")
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
live(conn, ~p"/beacons/#{beacon}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "logged-in non-owner non-admin gets 404 on unapproved beacon", %{conn: conn} do
|
||||||
|
beacon = beacon_fixture(user_fixture(), callsign: "W5HID")
|
||||||
|
conn = log_in_user(conn, user_fixture())
|
||||||
|
|
||||||
|
assert_raise Ecto.NoResultsError, fn ->
|
||||||
|
live(conn, ~p"/beacons/#{beacon}")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "admin sees unapproved beacon", %{conn: conn} do
|
||||||
|
beacon = beacon_fixture(user_fixture(), callsign: "W5ADM")
|
||||||
|
conn = log_in_user(conn, admin_user_fixture())
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}")
|
||||||
|
assert html =~ "Pending approval"
|
||||||
|
assert html =~ "W5ADM"
|
||||||
|
end
|
||||||
|
|
||||||
test "plot-path-to-beacon link pre-populates tx_power + gain to match beacon EIRP",
|
test "plot-path-to-beacon link pre-populates tx_power + gain to match beacon EIRP",
|
||||||
%{conn: conn} do
|
%{conn: conn} do
|
||||||
# power_mw: 10_000 → EIRP 40 dBm → default-gain 30 dBi → tx 10 dBm
|
# power_mw: 10_000 → EIRP 40 dBm → default-gain 30 dBi → tx 10 dBm
|
||||||
|
|
@ -358,12 +385,15 @@ defmodule MicrowavepropWeb.BeaconLiveTest do
|
||||||
refute html =~ "src_gain_dbi="
|
refute html =~ "src_gain_dbi="
|
||||||
end
|
end
|
||||||
|
|
||||||
test "non-admin unauthenticated user clicking 'approve' is rejected with a flash",
|
test "submitter clicking 'approve' on their own pending beacon is rejected with a flash",
|
||||||
%{conn: conn} do
|
%{conn: conn} do
|
||||||
# The approve handler is rendered for admins only but the server
|
# The approve handler is rendered for admins only but the server
|
||||||
# still defends the admin check. Simulate a crafted client pushing
|
# still defends the admin check. Simulate a crafted client pushing
|
||||||
# the event manually with no scope; the flash branch should fire.
|
# the event manually as the (non-admin) submitter; the flash branch
|
||||||
pending = beacon_fixture(user_fixture(), callsign: "W5DEF")
|
# should fire and the beacon must remain unapproved.
|
||||||
|
user = user_fixture()
|
||||||
|
pending = beacon_fixture(user, callsign: "W5DEF")
|
||||||
|
conn = log_in_user(conn, user)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/beacons/#{pending}")
|
{:ok, view, _html} = live(conn, ~p"/beacons/#{pending}")
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue