From fe3c8812a001220343c4262e8d2b340efbe5e87a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sun, 29 Mar 2026 17:24:12 -0500 Subject: [PATCH] Harden /submit route for production - Defensive grid-to-coordinate resolution in create_qso (case instead of crash on match failure) - Log warning when Oban weather enqueue fails instead of silently dropping - Add 30s per-session submission cooldown to prevent rapid-fire submissions --- lib/microwaveprop/radio.ex | 22 ++++++----- lib/microwaveprop_web/live/submit_live.ex | 45 ++++++++++++++++++----- 2 files changed, 48 insertions(+), 19 deletions(-) diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 45b6a929..8541d1ba 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -137,17 +137,21 @@ defmodule Microwaveprop.Radio do grid1 = Ecto.Changeset.get_change(changeset, :grid1) grid2 = Ecto.Changeset.get_change(changeset, :grid2) - {:ok, {lat1, lon1}} = Maidenhead.to_latlon(grid1) - {:ok, {lat2, lon2}} = Maidenhead.to_latlon(grid2) + case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do + {{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} -> + distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() - distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() + changeset + |> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1}) + |> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2}) + |> Ecto.Changeset.put_change(:distance_km, distance) + |> Ecto.Changeset.put_change(:user_submitted, true) + |> Repo.insert() - changeset - |> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1}) - |> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2}) - |> Ecto.Changeset.put_change(:distance_km, distance) - |> Ecto.Changeset.put_change(:user_submitted, true) - |> Repo.insert() + _ -> + changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates") + {:error, %{changeset | action: :insert}} + end else {:error, %{changeset | action: :insert}} end diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex index dd7e35c0..da47cd77 100644 --- a/lib/microwaveprop_web/live/submit_live.ex +++ b/lib/microwaveprop_web/live/submit_live.ex @@ -6,6 +6,8 @@ defmodule MicrowavepropWeb.SubmitLive do alias Microwaveprop.Radio.Qso alias Microwaveprop.Workers.QsoWeatherEnqueueWorker + require Logger + @band_options [ {"1296 MHz", "1296"}, {"2304 MHz", "2304"}, @@ -28,7 +30,8 @@ defmodule MicrowavepropWeb.SubmitLive do page_title: "Submit QSO", form: to_form(changeset), band_options: @band_options, - mode_options: @mode_options + mode_options: @mode_options, + submitted_at: nil )} end @@ -42,18 +45,40 @@ defmodule MicrowavepropWeb.SubmitLive do {:noreply, assign(socket, form: to_form(changeset))} end + # Minimum milliseconds between submissions per LiveView session. + # For broader IP-based rate limiting, use a reverse proxy (nginx limit_req, Cloudflare). + @submission_cooldown_ms 30_000 + def handle_event("save", %{"qso" => qso_params}, socket) do - case Radio.create_qso(qso_params) do - {:ok, qso} -> - Oban.insert(QsoWeatherEnqueueWorker.new(%{})) + if recently_submitted?(socket) do + {:noreply, put_flash(socket, :error, "Please wait before submitting another QSO.")} + else + case Radio.create_qso(qso_params) do + {:ok, qso} -> + case Oban.insert(QsoWeatherEnqueueWorker.new(%{})) do + {:ok, _job} -> + :ok - {:noreply, - socket - |> put_flash(:info, "QSO submitted successfully!") - |> push_navigate(to: ~p"/qsos/#{qso.id}")} + {:error, reason} -> + Logger.warning("Failed to enqueue weather processing: #{inspect(reason)}") + end - {:error, changeset} -> - {:noreply, assign(socket, form: to_form(changeset))} + {:noreply, + socket + |> assign(submitted_at: System.monotonic_time(:millisecond)) + |> put_flash(:info, "QSO submitted successfully!") + |> push_navigate(to: ~p"/qsos/#{qso.id}")} + + {:error, changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + end + + defp recently_submitted?(socket) do + case socket.assigns.submitted_at do + nil -> false + ts -> System.monotonic_time(:millisecond) - ts < @submission_cooldown_ms end end