From 2b14e836f14cd3adf05f6ea0e6e3860f5c88635a Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Thu, 2 Apr 2026 12:32:59 -0500 Subject: [PATCH] Fix CSV parsing and page number input validation - RFC 4180 CSV parser: handles quoted fields with commas and escaped quotes ("Smith, John" no longer breaks parsing) - Safe page number parsing: non-numeric input defaults to page 1 instead of raising ArgumentError --- lib/microwaveprop/radio/csv_import.ex | 44 ++++++++++++++++++- .../live/contact_live/index.ex | 6 ++- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex index a2844c02..b90711a1 100644 --- a/lib/microwaveprop/radio/csv_import.ex +++ b/lib/microwaveprop/radio/csv_import.ex @@ -56,7 +56,7 @@ defmodule Microwaveprop.Radio.CsvImport do end defp parse_and_import_row(line, submitter_email) do - fields = line |> String.split(",") |> Enum.map(&String.trim/1) + fields = parse_csv_fields(line) if length(fields) == @expected_columns do attrs = @@ -174,6 +174,48 @@ defmodule Microwaveprop.Radio.CsvImport do {:ok, "#{y}-#{m}-#{d}T#{h}:#{mi}:#{s}Z"} end + # RFC 4180 CSV field parser. Handles quoted fields with commas and escaped quotes. + defp parse_csv_fields(line) do + line + |> String.trim() + |> do_parse_fields([], "") + |> Enum.reverse() + |> Enum.map(&String.trim/1) + end + + defp do_parse_fields("", acc, current), do: [current | acc] + + defp do_parse_fields(<<"\"", rest::binary>>, acc, "") do + parse_quoted_field(rest, acc, "") + end + + defp do_parse_fields(<<",", rest::binary>>, acc, current) do + do_parse_fields(rest, [current | acc], "") + end + + defp do_parse_fields(<>, acc, current) do + do_parse_fields(rest, acc, current <> <>) + end + + defp parse_quoted_field(<<"\"\"", rest::binary>>, acc, current) do + parse_quoted_field(rest, acc, current <> "\"") + end + + defp parse_quoted_field(<<"\"", rest::binary>>, acc, current) do + # End of quoted field — skip to next comma or end + case rest do + <<"," , rest2::binary>> -> do_parse_fields(rest2, [current | acc], "") + "" -> [current | acc] + _ -> do_parse_fields(rest, [current | acc], "") + end + end + + defp parse_quoted_field(<>, acc, current) do + parse_quoted_field(rest, acc, current <> <>) + end + + defp parse_quoted_field("", acc, current), do: [current | acc] + defp changeset_error_strings(changeset) do changeset |> Ecto.Changeset.traverse_errors(fn {message, opts} -> diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex index 386c78a8..c88659c5 100644 --- a/lib/microwaveprop_web/live/contact_live/index.ex +++ b/lib/microwaveprop_web/live/contact_live/index.ex @@ -15,7 +15,11 @@ defmodule MicrowavepropWeb.ContactLive.Index do @impl true def handle_params(params, _uri, socket) do - page = params |> Map.get("page", "1") |> String.to_integer() |> max(1) + page = + case Integer.parse(Map.get(params, "page", "1")) do + {n, _} -> max(n, 1) + :error -> 1 + end sort_by = validate_sort_field(Map.get(params, "sort_by", @default_sort_by)) sort_order = validate_sort_order(Map.get(params, "sort_order", @default_sort_order)) search = Map.get(params, "search", "")