prop/lib/microwaveprop_web/controllers/api/v1/contact_json.ex
Graham McIntire c6d2c48264
feat: secure /api/v1 REST API for regular-user actions
Adds bearer-token authenticated REST API at /api/v1 covering every
action a non-admin user can perform on the website: contact + beacon
submission, beacon-monitor management, propagation queries, profile
read/update, and self-service API token issuance/revocation.

Security: SHA-256-hashed bearer tokens (mwp_ prefix, plaintext shown
once at creation), RFC 9457 problem+json error responses, RFC 9651
RateLimit-* headers backed by an ETS bucket (600/min per token,
60/min per anonymous IP, 30/min on /auth/tokens), private-contact
filtering by viewer.

Docs at docs/api/README.md (prose reference) and docs/api/openapi.yaml
(OpenAPI 3.1 spec covering every endpoint, response, and schema).

Tests: 124 new tests across schema, plug, error renderer, rate
limiter, fallback, and every controller. 16/17 API modules at 100%
line coverage; FallbackController at 87.5% (one defmodule line, an
Erlang-cover artifact for action_fallback-only modules).
2026-05-09 08:59:54 -05:00

61 lines
1.9 KiB
Elixir

defmodule MicrowavepropWeb.Api.V1.ContactJSON do
@moduledoc "Renders contact (QSO) representations."
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Contact
def index(%{contacts: contacts} = assigns) do
viewer = Map.get(assigns, :viewer)
%{data: Enum.map(contacts, &data(&1, viewer))}
end
def index_paginated(%{contacts: contacts} = assigns) do
viewer = Map.get(assigns, :viewer)
%{
data: Enum.map(contacts, &data(&1, viewer)),
meta: %{
page: assigns.page,
per_page: assigns.per_page,
total_entries: assigns.total_entries,
total_pages: assigns.total_pages
}
}
end
def show(%{contact: contact} = assigns) do
viewer = Map.get(assigns, :viewer)
%{data: data(contact, viewer)}
end
defp data(%Contact{} = c, viewer) do
%{
id: c.id,
station1: c.station1,
station2: c.station2,
qso_timestamp: c.qso_timestamp,
grid1: c.grid1,
grid2: c.grid2,
pos1: c.pos1,
pos2: c.pos2,
mode: c.mode,
band_mhz: c.band && Decimal.to_string(c.band),
distance_km: c.distance_km && Decimal.to_string(c.distance_km),
private: c.private,
user_declared_prop_mode: c.user_declared_prop_mode,
propagation_mechanism: c.propagation_mechanism,
propagation_mechanism_confidence: c.propagation_mechanism_confidence,
notes: maybe_notes(c, viewer),
mine?: mine?(c, viewer)
}
end
# Private notes are only visible to the submitter; everyone else sees nil.
defp maybe_notes(%Contact{user_id: nil}, _viewer), do: nil
defp maybe_notes(%Contact{notes: nil}, _viewer), do: nil
defp maybe_notes(%Contact{user_id: uid, notes: notes}, %User{id: uid}), do: notes
defp maybe_notes(_contact, _viewer), do: nil
defp mine?(%Contact{user_id: uid}, %User{id: uid}) when not is_nil(uid), do: true
defp mine?(_contact, _viewer), do: false
end