prop/lib/microwaveprop/radio/contact.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

233 lines
8 KiB
Elixir

defmodule Microwaveprop.Radio.Contact do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "contacts" do
field :station1, :string
field :station2, :string
field :qso_timestamp, :utc_datetime
field :grid1, :string
field :grid2, :string
field :pos1, :map
field :pos2, :map
field :mode, :string
field :band, :decimal
field :distance_km, :decimal
field :hrrr_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :weather_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :terrain_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :iemre_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :radar_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
# ADIF PROP_MODE string as submitted by the operator. Trusted as
# ground truth by MechanismClassifier when present.
field :user_declared_prop_mode, :string
# Output of Microwaveprop.Propagation.MechanismClassifier. Values
# align with the classifier's result type — kept as a string for
# schema-free extension (new mechanisms don't require a migration).
field :propagation_mechanism, :string
field :propagation_mechanism_confidence, Ecto.Enum, values: [:high, :medium, :low]
field :mechanism_status, Ecto.Enum,
values: [:pending, :queued, :processing, :complete, :failed, :unavailable],
default: :pending
field :user_submitted, :boolean, default: false
field :submitter_email, :string
field :submitter_verified, :boolean, default: false
field :flagged_invalid, :boolean, default: false
field :flagged_at, :utc_datetime
field :private, :boolean, default: false
# Antenna height above ground level (feet). Optional — when set, the
# elevation profile and terrain analysis use the actual heights
# instead of a default 10 ft.
field :height1_ft, :integer
field :height2_ft, :integer
# Free-form operator notes captured at submission time (QSO
# commentary, propagation mode observed, equipment details, etc).
# Purely informational — not consumed by the scoring pipeline.
field :notes, :string
belongs_to :user, User
belongs_to :flagged_by_user, User, foreign_key: :flagged_by_user_id
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km mode user_declared_prop_mode height1_ft height2_ft private notes)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(contact, attrs) do
contact
|> cast(attrs, @required_fields ++ @optional_fields)
|> validate_required(@required_fields)
end
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private notes)a
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and
# Microwaveprop.Radio.AdifImport.@allowed_bands.
@allowed_bands Enum.map(
~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000),
&Decimal.new/1
)
@spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def submission_changeset(contact, attrs) do
contact
|> cast(attrs, @submission_fields)
|> validate_required(@submission_required)
|> validate_user_or_email()
|> sanitize_callsign(:station1)
|> sanitize_callsign(:station2)
|> sanitize_grid(:grid1)
|> sanitize_grid(:grid2)
|> normalize_blank_mode()
|> validate_callsign(:station1)
|> validate_callsign(:station2)
|> validate_grid_format(:grid1)
|> validate_grid_format(:grid2)
|> validate_mode_inclusion()
|> validate_inclusion(:band, @allowed_bands)
|> validate_email_format()
|> validate_length(:submitter_email, max: 254)
|> validate_length(:station1, max: 20)
|> validate_length(:station2, max: 20)
|> validate_height(:height1_ft)
|> validate_height(:height2_ft)
|> normalize_blank_notes()
|> validate_length(:notes, max: 2000)
end
# Collapse whitespace-only notes to `nil` so the DB column reflects
# "no notes" instead of an empty string. Any non-blank value is kept
# as-is (including internal whitespace) so the operator's original
# wording round-trips untouched.
defp normalize_blank_notes(changeset) do
case get_change(changeset, :notes) do
nil ->
changeset
value ->
if String.trim(value) == "" do
put_change(changeset, :notes, nil)
else
changeset
end
end
end
# Sanity bounds on AGL antenna height. Negative heights and anything
# taller than ~1000 ft are almost certainly data-entry errors.
defp validate_height(changeset, field) do
validate_number(changeset, field, greater_than_or_equal_to: 0, less_than_or_equal_to: 1000)
end
# Treat empty / whitespace-only mode strings as "not provided" so the column
# ends up NULL instead of failing validate_inclusion. Mode is optional on the
# submission path.
defp normalize_blank_mode(changeset) do
case get_change(changeset, :mode) do
nil ->
changeset
value ->
case String.trim(value) do
"" -> put_change(changeset, :mode, nil)
trimmed -> put_change(changeset, :mode, trimmed)
end
end
end
# Only run validate_inclusion for mode when the field has a non-nil change.
# nil means "not provided" (mode is optional), and put_change to nil would
# otherwise cause validate_inclusion to reject nil as not in the allowed list.
defp validate_mode_inclusion(changeset) do
case get_change(changeset, :mode) do
nil -> changeset
_mode -> validate_inclusion(changeset, :mode, @allowed_modes)
end
end
# Either a user_id (logged-in submitter) or a submitter_email (anonymous)
# must be present so we know who submitted the contact.
defp validate_user_or_email(changeset) do
user_id = get_field(changeset, :user_id)
email = get_field(changeset, :submitter_email)
if user_id || (email && email != "") do
changeset
else
add_error(changeset, :submitter_email, "can't be blank")
end
end
defp validate_email_format(changeset) do
case get_field(changeset, :submitter_email) do
nil -> changeset
"" -> changeset
_email -> validate_format(changeset, :submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/)
end
end
# Strip whitespace and upcase callsigns
defp sanitize_callsign(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Upcase grid squares (Maidenhead uses uppercase letters + digits)
defp sanitize_grid(changeset, field) do
case get_change(changeset, field) do
nil -> changeset
val -> put_change(changeset, field, val |> String.trim() |> String.upcase())
end
end
# Callsigns: letters, digits, and / only (e.g. W5XD, KG5CCI/P, VE3/W5XD)
defp validate_callsign(changeset, field) do
validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /")
end
defp validate_grid_format(changeset, field) do
validate_change(changeset, field, fn _, value ->
if Maidenhead.valid?(value) do
[]
else
[{field, "is not a valid Maidenhead grid square"}]
end
end)
end
end