- 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.
103 lines
3 KiB
Elixir
103 lines
3 KiB
Elixir
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
|