prop/lib/microwaveprop_web/controllers/api/v1/contact_controller.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

103 lines
2.9 KiB
Elixir

defmodule MicrowavepropWeb.Api.V1.ContactController do
@moduledoc "Read + create contacts (QSOs)."
use Phoenix.Controller, formats: [:json]
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
alias MicrowavepropWeb.Api.ErrorJSON
alias MicrowavepropWeb.Api.V1.ContactJSON
plug :accepts, ["json"]
action_fallback MicrowavepropWeb.Api.FallbackController
@max_per_page 200
def index(conn, params) do
page = params |> Map.get("page", "1") |> parse_int(1)
per_page = params |> Map.get("per_page", "50") |> parse_int(50) |> min(@max_per_page) |> max(1)
search = params["search"]
viewer = conn.assigns[:current_api_user]
%{entries: entries, total_entries: total, total_pages: total_pages} =
Radio.list_contacts(
page: page,
search: search,
scope: Scope.for_user(viewer)
)
json(
conn,
ContactJSON.index_paginated(%{
contacts: entries,
viewer: viewer,
page: page,
per_page: per_page,
total_entries: total,
total_pages: total_pages
})
)
end
def show(conn, %{"id" => id}) do
viewer = conn.assigns[:current_api_user]
case Repo.get(Contact, id) do
nil ->
ErrorJSON.send_problem(conn, 404, "not_found", "Contact not found.")
%Contact{private: true, user_id: owner_id} = contact ->
if owner_id && viewer && viewer.id == owner_id do
json(conn, ContactJSON.show(%{contact: contact, viewer: viewer}))
else
ErrorJSON.send_problem(conn, 404, "not_found", "Contact not found.")
end
%Contact{} = contact ->
json(conn, ContactJSON.show(%{contact: contact, viewer: viewer}))
end
end
def create(conn, params) do
user = conn.assigns.current_api_user
attrs =
params
|> Map.take(
~w(station1 station2 qso_timestamp mode band grid1 grid2 user_declared_prop_mode height1_ft height2_ft private notes)
)
|> Map.put_new("submitter_email", user.email)
case Radio.create_contact(attrs, user.id) do
{:ok, contact} ->
conn
|> put_status(:created)
|> json(ContactJSON.show(%{contact: contact, viewer: user}))
{:error, %Ecto.Changeset{} = changeset} ->
ErrorJSON.send_changeset(conn, changeset)
{:error, :duplicate, existing} ->
conn
|> put_status(:conflict)
|> json(%{
type: "about:blank",
title: "duplicate_contact",
status: 409,
detail: "An equivalent contact already exists.",
existing: ContactJSON.show(%{contact: existing, viewer: user}).data
})
end
end
defp parse_int(value, fallback) when is_binary(value) do
case Integer.parse(value) do
{n, ""} when n >= 1 -> n
_ -> fallback
end
end
defp parse_int(_, fallback), do: fallback
end