Fix 7 high-severity bugs
- mechanism_classifier: add nil guards on get_lat/get_lon (no crash on bad radar lookup) - path_compute: use Map.get with fallback for abs_humidity (no nil * dist_km crash) - beacon_live/index: fix path_with_prefix double-slash with trim_leading - map_live: inline flash rendering instead of calling Layouts.flash_group - runtime.exs: raise on missing EMAIL_SERVER (prevents silent TLS failure) - notify_listener: wrap Task.start in try/rescue (prevents linked crash on GenServer) - notify_listener: add Process.monitor on PG notifications (detect connection loss) - findings.md: remove fixed high-severity items
This commit is contained in:
parent
3bdc4b6a11
commit
5ca5edd554
7 changed files with 55 additions and 42 deletions
|
|
@ -139,7 +139,8 @@ if config_env() == :prod do
|
|||
# behind a wildcard cert (*.smtp2go.com) so SNI is mandatory, and gen_smtp
|
||||
# won't set it without explicit server_name_indication. Without this, the
|
||||
# handshake fails with an "unexpected_message" TLS alert.
|
||||
email_server = System.get_env("EMAIL_SERVER")
|
||||
email_server =
|
||||
System.get_env("EMAIL_SERVER") || raise("EMAIL_SERVER must be set (SMTP relay hostname)")
|
||||
|
||||
# Production Oban: live scoring, polling, and on-demand QSO enrichment.
|
||||
# Runs on the Oban Pro Smart engine so we can use global_limit / rate_limit
|
||||
|
|
|
|||
50
findings.md
50
findings.md
|
|
@ -2,46 +2,34 @@
|
|||
|
||||
## Bugs
|
||||
|
||||
### 🟠 High
|
||||
|
||||
| # | File | Line | Issue | Suggested Fix |
|
||||
|---|------|------|-------|---------------|
|
||||
| 1 | `mechanism_classifier.ex` | 314-317 | `get_lat`/`get_lon` crash on nil — `nil / 1.0` raises `ArithmeticError` if radar lookup returns nil coords | Add pattern match for nil or guard before division |
|
||||
| 2 | `path_compute.ex` | 433 | `conditions.abs_humidity` nil crash — `nil * dist_km` raises if conditions map is missing `:abs_humidity` | Use `Map.get(conditions, :abs_humidity, 7.5)` with fallback |
|
||||
| 3 | `beacon_live/index.ex` | 119 | `path_with_prefix` creates `//beacons` URLs — `"/" <> path` doubles slash on already-prefixed paths | `String.trim_leading(path, "/")` |
|
||||
| 4 | `map_live.ex` | 1053 | `flash_group` called outside `layouts.ex` — violates AGENTS.md rule | Include equivalent in-line FlashGroup component or restructure to use `Layouts.app` |
|
||||
| 5 | `config/runtime.exs` | 142, 386 | `EMAIL_SERVER` not validated — unset produces empty SNI causing silent TLS handshake failure | Raise at boot when required env vars are missing (like `DATABASE_URL` does) |
|
||||
| 6 | `notify_listener.ex` | 119-126 | `Task.start/1` linked to caller — crash in task kills the GenServer, contradicting moduledoc | Use `Task.Supervisor.start_child` or wrap body in `try/rescue` |
|
||||
| 7 | `notify_listener.ex` | 36, 40-51 | No `Process.monitor` on `Postgrex.Notifications` — if PG notify connection crashes, GenServer never learns | Add `Process.monitor` and handle `{:DOWN, ...}` in `handle_info` |
|
||||
|
||||
### 🟡 Medium
|
||||
|
||||
| # | File | Line | Issue | Suggested Fix |
|
||||
|---|------|------|-------|---------------|
|
||||
| 8 | `radio.ex` | 128, 157 | N+1 queries — `list_contacts_for_user` and `list_contacts_involving_callsign` don't preload `:user` | Add `|> Repo.preload(:user)` before `Repo.all()` |
|
||||
| 9 | `radio.ex` | 751-757 | Missing indexes — dedup query filters on `band`, `qso_timestamp`, `flagged_invalid` with no covering index | Add partial index on `(band, qso_timestamp) WHERE flagged_invalid = false` |
|
||||
| 10 | `grid.ex` | 31-36 | `conus_points/0` recomputes ~94k elements on every call — pure function, never memoized | Memoize with module attribute `@conus_points conus_points()` |
|
||||
| 11 | `endpoint.ex` | 7-12 | Session cookie is signed but not encrypted — payload is readable though tamper-proof | Add `encryption_salt` to `@session_options` |
|
||||
| 12 | `accounts.ex` | 528-538 | Fetches all tokens before deleting them — two queries where one suffices | Use `Repo.delete_all(from t in UserToken, where: t.user_id == ^user.id)` |
|
||||
| 13 | `profiles_file.ex` | 169-177 | Unhandled `:zlib.gunzip/1` exception — corrupt gzip files raise through cache wrapper | Wrap in `try/rescue` |
|
||||
| 14 | `scores_file.ex` | 515 | Direct ETS `match_delete` bypassing Cache API | Use `Cache.invalidate` or `Cache` public API |
|
||||
| 15 | `profiles_file.ex` | 323 | Same direct ETS access as above | Same fix |
|
||||
| 16 | `path_compute.ex` | 381-394 | Six redundant list traversals — 12 passes over 9 profiles for what could be 1 `Enum.reduce` | Single pass with `Enum.reduce` |
|
||||
| 17 | `hf_muf.ex` | 51 | `sec_i_factor(@ref_distance_km)` recomputed on every call — constant input | Precompute with module attribute |
|
||||
| 18 | `config/config.exs` | 163 | `EXLA.Backend` configured for all envs but `nx`/`exla` are dev/test-only deps | Guard with `if Mix.env() in [:dev, :test]` |
|
||||
| 1 | `radio.ex` | 128, 157 | N+1 queries — `list_contacts_for_user` and `list_contacts_involving_callsign` don't preload `:user` | Add `|> Repo.preload(:user)` before `Repo.all()` |
|
||||
| 2 | `radio.ex` | 751-757 | Missing indexes — dedup query filters on `band`, `qso_timestamp`, `flagged_invalid` with no covering index | Add partial index on `(band, qso_timestamp) WHERE flagged_invalid = false` |
|
||||
| 3 | `grid.ex` | 31-36 | `conus_points/0` recomputes ~94k elements on every call — pure function, never memoized | Memoize with module attribute `@conus_points conus_points()` |
|
||||
| 4 | `endpoint.ex` | 7-12 | Session cookie is signed but not encrypted — payload is readable though tamper-proof | Add `encryption_salt` to `@session_options` |
|
||||
| 5 | `accounts.ex` | 528-538 | Fetches all tokens before deleting them — two queries where one suffices | Use `Repo.delete_all(from t in UserToken, where: t.user_id == ^user.id)` |
|
||||
| 6 | `profiles_file.ex` | 169-177 | Unhandled `:zlib.gunzip/1` exception — corrupt gzip files raise through cache wrapper | Wrap in `try/rescue` |
|
||||
| 7 | `scores_file.ex` | 515 | Direct ETS `match_delete` bypassing Cache API | Use `Cache.invalidate` or `Cache` public API |
|
||||
| 8 | `profiles_file.ex` | 323 | Same direct ETS access as above | Same fix |
|
||||
| 9 | `path_compute.ex` | 381-394 | Six redundant list traversals — 12 passes over 9 profiles for what could be 1 `Enum.reduce` | Single pass with `Enum.reduce` |
|
||||
| 10 | `hf_muf.ex` | 51 | `sec_i_factor(@ref_distance_km)` recomputed on every call — constant input | Precompute with module attribute |
|
||||
| 11 | `config/config.exs` | 163 | `EXLA.Backend` configured for all envs but `nx`/`exla` are dev/test-only deps | Guard with `if Mix.env() in [:dev, :test]` |
|
||||
|
||||
### 🟢 Low
|
||||
|
||||
| # | File | Line | Issue | Suggested Fix |
|
||||
|---|------|------|-------|---------------|
|
||||
| 19 | `router.ex` | 165 | Login rate limit 30/min per IP may be generous for brute-force | Consider lowering to 10-15/min |
|
||||
| 20 | `application.ex` | 107-109 | Dev/test model loading blocks supervisor start | Wrap in `Task.start` |
|
||||
| 21 | `scorer.ex` | 126-138 | `classify_time_period` guard boundaries have gap at exactly `-3.0` | Switch to `cond` with inclusive/exclusive clarity |
|
||||
| 22 | `scorer.ex` | 636 | `||` for default when `//` is more intention-revealing | Replace `contact.pos1["lon"] \|\| -97.0` with `//` |
|
||||
| 23 | `path_compute.ex` | 356-357 | Eager `Repo.get(Station, ...)` instead of preloading assoc | Preload `:station` upstream |
|
||||
| 24 | `radio.ex` | 1203 | Hardcoded cache key `{ContactMapController, :gzipped_payload}` fragile to module rename | Extract to a named key in `ContactMapController` |
|
||||
| 25 | `user.ex` | — | Missing `has_many :contacts` and `has_many :beacons` associations | Add for convenience (not a bug, test callers use raw queries) |
|
||||
| 26 | `radio.ex` | 337 | Missing partial indexes for enrichment queries | Create filtered indexes on `(qso_timestamp) WHERE weather_status IN ('pending','failed')` |
|
||||
| 12 | `router.ex` | 165 | Login rate limit 30/min per IP may be generous for brute-force | Consider lowering to 10-15/min |
|
||||
| 13 | `application.ex` | 107-109 | Dev/test model loading blocks supervisor start | Wrap in `Task.start` |
|
||||
| 14 | `scorer.ex` | 126-138 | `classify_time_period` guard boundaries have gap at exactly `-3.0` | Switch to `cond` with inclusive/exclusive clarity |
|
||||
| 15 | `scorer.ex` | 636 | `||` for default when `//` is more intention-revealing | Replace `contact.pos1["lon"] \|\| -97.0` with `//` |
|
||||
| 16 | `path_compute.ex` | 356-357 | Eager `Repo.get(Station, ...)` instead of preloading assoc | Preload `:station` upstream |
|
||||
| 17 | `radio.ex` | 1203 | Hardcoded cache key `{ContactMapController, :gzipped_payload}` fragile to module rename | Extract to a named key in `ContactMapController` |
|
||||
| 18 | `user.ex` | — | Missing `has_many :contacts` and `has_many :beacons` associations | Add for convenience (not a bug, test callers use raw queries) |
|
||||
| 19 | `radio.ex` | 337 | Missing partial indexes for enrichment queries | Create filtered indexes on `(qso_timestamp) WHERE weather_status IN ('pending','failed')` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -311,8 +311,10 @@ defmodule Microwaveprop.Propagation.MechanismClassifier do
|
|||
|
||||
defp try_line_of_sight(_), do: :no_match
|
||||
|
||||
defp get_lat(%{"lat" => lat}), do: lat / 1.0
|
||||
defp get_lat(%{lat: lat}), do: lat / 1.0
|
||||
defp get_lon(%{"lon" => lon}), do: lon / 1.0
|
||||
defp get_lon(%{lon: lon}), do: lon / 1.0
|
||||
defp get_lat(%{"lat" => lat}) when is_number(lat), do: lat / 1.0
|
||||
defp get_lat(%{lat: lat}) when is_number(lat), do: lat / 1.0
|
||||
defp get_lat(_), do: nil
|
||||
defp get_lon(%{"lon" => lon}) when is_number(lon), do: lon / 1.0
|
||||
defp get_lon(%{lon: lon}) when is_number(lon), do: lon / 1.0
|
||||
defp get_lon(_), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ defmodule Microwaveprop.Propagation.NotifyListener do
|
|||
def handle_info(:subscribe, state) do
|
||||
case start_notifications_conn() do
|
||||
{:ok, pid, ref} ->
|
||||
Process.monitor(pid)
|
||||
{:noreply, %{state | pid: pid, ref: ref}}
|
||||
|
||||
{:error, reason} ->
|
||||
|
|
@ -51,6 +52,13 @@ defmodule Microwaveprop.Propagation.NotifyListener do
|
|||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:DOWN, _ref, :process, pid, reason}, %{pid: pid} = state) do
|
||||
Logger.warning("NotifyListener: Postgrex.Notifications connection (#{inspect(pid)}) down: #{inspect(reason)}. Reconnecting…")
|
||||
send(self(), :subscribe)
|
||||
{:noreply, %{state | pid: nil, ref: nil}}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:notification, _pid, _ref, @channel, payload}, state) do
|
||||
case DateTime.from_iso8601(payload) do
|
||||
|
|
@ -117,10 +125,17 @@ defmodule Microwaveprop.Propagation.NotifyListener do
|
|||
# NotifyListener GenServer never blocks on NFS I/O. The materializer is
|
||||
# itself idempotent and self-rescuing, so failures only land in logs.
|
||||
defp kickoff_scalar_materialization(valid_time) do
|
||||
{:ok, _pid} =
|
||||
Task.start(fn ->
|
||||
# Spawn in a try/rescue so a crash in the materializer does not
|
||||
# propagate through the linked Task and kill the NotifyListener
|
||||
# GenServer.
|
||||
Task.start(fn ->
|
||||
try do
|
||||
Weather.materialize_scalar_file(valid_time)
|
||||
end)
|
||||
rescue
|
||||
e ->
|
||||
Logger.error("NotifyListener: scalar materialization crashed: #{Exception.format(:error, e, __STACKTRACE__)}")
|
||||
end
|
||||
end)
|
||||
|
||||
:ok
|
||||
end
|
||||
|
|
|
|||
|
|
@ -430,7 +430,7 @@ defmodule Microwaveprop.Propagation.PathCompute do
|
|||
|
||||
abs_humidity =
|
||||
if conditions do
|
||||
conditions.abs_humidity
|
||||
Map.get(conditions, :abs_humidity) || 7.5
|
||||
else
|
||||
7.5
|
||||
end
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|
|||
defp admin?(%{user: %{is_admin: true}}), do: true
|
||||
defp admin?(_), do: false
|
||||
|
||||
defp path_with_prefix(path) when is_binary(path), do: "/" <> path
|
||||
defp path_with_prefix(path) when is_binary(path), do: "/" <> String.trim_leading(path, "/")
|
||||
defp path_with_prefix(_), do: "/beacons"
|
||||
|
||||
defp encode_beacons(beacons) do
|
||||
|
|
|
|||
|
|
@ -1050,7 +1050,14 @@ defmodule MicrowavepropWeb.MapLive do
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<Layouts.flash_group flash={@flash} />
|
||||
<div role="alert" class="fixed top-2 right-2 z-[2000] flex flex-col gap-2 pointer-events-none">
|
||||
<div :if={info = Phoenix.Flash.get(@flash, :info)} class="alert alert-info pointer-events-auto shadow-lg">
|
||||
{info}
|
||||
</div>
|
||||
<div :if={error = Phoenix.Flash.get(@flash, :error)} class="alert alert-error pointer-events-auto shadow-lg">
|
||||
{error}
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue