prop/lib/microwaveprop_web/router.ex
Graham McIntire 316fb2fbc7
Fix low-severity bugs and re-enable Credo checks
- Bug #12: Lower rate limit to 15/min
- Bug #13: Wrap model loading in Task.start
- Bug #14: Fix classify_time_period guard gap at -3.0
- Bug #15: Not applicable (Elixir has no ?? operator)
- Bug #16: Remove fallback Repo.get for station preload
- Bug #17: Extract cache_key() in ContactMapController
- Bug #18: Add has_many :contacts and :beacons to User schema
- A&D #4: Move serve_markdown_if_requested after secure headers
- Config #1: Move signing_salt to runtime.exs env var
- Config #2: Use --check-unused instead of --unused
- Config #3: Re-enable UnsafeToAtom Credo check
- Config #5: Re-enable LeakyEnvironment Credo check
- Add @spec annotations to fix re-enabled Specs violations
- Replace String.to_atom with to_existing_atom where guarded
2026-05-29 17:29:22 -05:00

379 lines
14 KiB
Elixir
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

defmodule MicrowavepropWeb.Router do
use MicrowavepropWeb, :router
import MicrowavepropWeb.UserAuth
import Oban.Web.Router
import Phoenix.LiveDashboard.Router
alias MicrowavepropWeb.Api.Auth
alias MicrowavepropWeb.Api.MonitorAuth
alias MicrowavepropWeb.Api.RateLimiter
alias MicrowavepropWeb.Api.V1
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :store_remote_ip
plug :store_cf_geo
plug :fetch_live_flash
plug :put_root_layout, html: {MicrowavepropWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :put_agent_link_headers
plug :serve_markdown_if_requested
plug :fetch_current_scope_for_user
end
# RFC 8288 Link headers advertising resources for agent/crawler discovery.
# `/algo` is the human-readable algorithm/service documentation, rendered
# from `algo.md`. `/sitemap.xml` is the registered "sitemap" rel. About
# and privacy use their IANA-registered rels.
@agent_link_header Enum.join(
[
~s(</algo>; rel="service-doc"),
~s(</about>; rel="about"),
~s(</privacy>; rel="privacy-policy"),
~s(</sitemap.xml>; rel="sitemap")
],
", "
)
defp put_agent_link_headers(conn, _opts) do
Plug.Conn.put_resp_header(conn, "link", @agent_link_header)
end
# Markdown for Agents: if the client prefers `text/markdown`, serve real
# markdown for the paths where we have markdown source. Other paths fall
# through to the `:accepts` plug and 406 — we don't synthesize markdown
# from LiveView HTML on the fly, since a lossy conversion would mislead
# agents about site content.
@external_resource "algo.md"
@algo_markdown File.read!("algo.md")
@homepage_markdown """
# NTMS Propagation Prediction
The North Texas Microwave Society's propagation prediction service for
amateur radio bands 10241 GHz. Scores propagation conditions across
CONUS using HRRR numerical weather prediction, sounding data, ITU-R
atmospheric models, and calibration against 57,000+ recorded QSOs.
## Primary Resources
- [Propagation map](/map) — real-time CONUS propagation scores + 18-hour forecast.
- [Algorithm documentation](/algo) — full methodology, factor weights, ITU-R references (also available as `text/markdown`).
- [Contact database](/contacts) — 58,000+ historical QSOs used for calibration.
- [Contact map](/contacts/map) — geographic view of all recorded contacts.
- [Path calculator](/path) — point-to-point propagation prediction.
- [Rover planner](/rover) — propagation heatmap with ranked drive-to candidates.
- [Rover locations](/rover-locations) — shared directory of rover-friendly parking spots.
- [Rover planning](/rover-planning) — planned rover missions and their paths.
- [Beacons](/beacons) — microwave beacon directory.
- [Submit a contact](/submit) — add a QSO to the calibration dataset.
## Meta
- [About](/about)
- [Privacy](/privacy)
- [Sitemap](/sitemap.xml)
## API
- `GET /api/contacts/map` — JSON tuples of every contact on the map (lat/lon/band/callsigns/mode/distance/timestamp/id). Pre-gzipped, ~5 MB decompressed.
"""
defp serve_markdown_if_requested(conn, _opts) do
if wants_markdown?(conn) do
case markdown_for_path(conn.request_path) do
nil ->
conn
body ->
conn
|> Plug.Conn.put_resp_content_type("text/markdown", "utf-8")
|> Plug.Conn.put_resp_header("x-markdown-tokens", estimate_tokens(body))
|> Plug.Conn.send_resp(200, body)
|> Plug.Conn.halt()
end
else
conn
end
end
defp wants_markdown?(conn) do
conn
|> Plug.Conn.get_req_header("accept")
|> Enum.any?(&String.contains?(&1, "text/markdown"))
end
defp markdown_for_path("/"), do: @homepage_markdown
defp markdown_for_path("/algo"), do: @algo_markdown
defp markdown_for_path(_), do: nil
# Rough token estimate: ~4 chars per token is the widely-used OpenAI
# heuristic. Good enough for the `x-markdown-tokens` hint.
defp estimate_tokens(body), do: body |> byte_size() |> div(4) |> Integer.to_string()
defp store_remote_ip(conn, _opts) do
ip_str = conn.remote_ip |> :inet.ntoa() |> to_string()
Plug.Conn.put_session(conn, :remote_ip, ip_str)
end
# Extract Cloudflare visitor geolocation headers (set by a Cloudflare
# Managed Transform) and stash them in the session so LiveViews can use
# them to center maps on the visitor's approximate location.
defp store_cf_geo(conn, _opts) do
lat = conn |> Plug.Conn.get_req_header("cf-iplatitude") |> List.first()
lon = conn |> Plug.Conn.get_req_header("cf-iplongitude") |> List.first()
with {lat_f, ""} when is_float(lat_f) <- lat && Float.parse(lat),
{lon_f, ""} when is_float(lon_f) <- lon && Float.parse(lon) do
conn
|> Plug.Conn.put_session(:cf_lat, lat_f)
|> Plug.Conn.put_session(:cf_lon, lon_f)
else
_ -> conn
end
end
pipeline :api do
plug :accepts, ["json"]
end
# Public read pipeline: optional bearer auth (so /me-aware queries can
# surface viewer-private data when a token is present), then rate
# limiting. No CSRF/session — pure JSON.
pipeline :api_v1_public do
plug :accepts, ["json"]
plug Auth, mode: :optional
plug RateLimiter
end
# Authenticated pipeline: bearer auth required, then rate limiting
# against the token's bucket.
pipeline :api_v1_authed do
plug :accepts, ["json"]
plug Auth, mode: :require
plug RateLimiter
end
# Login pipeline: same shape as :api but rate-limited per-IP because
# a missing token would otherwise burn through the anon bucket
# before authentication has a chance to bind a token.
pipeline :api_v1_login do
plug :accepts, ["json"]
plug RateLimiter, anon_limit: 15
end
# propmonitor beacon-measurement uploads. Bearer token resolves to a
# `BeaconMonitor` rather than a user. One POST per integration window
# (default 60 s); allow a comfortable burst headroom for retry storms
# after a 5xx — well above the steady-state 1/min/monitor rate.
pipeline :api_v1_monitor do
plug :accepts, ["json"]
plug MonitorAuth
plug RateLimiter, auth_limit: 60
end
# Health checks — no pipeline, minimal overhead.
# /live = liveness, BEAM only. /health = readiness, also pings Repo.
# Pods that serve a BEAM-alive /live but a failing /health get taken
# out of Service endpoints without SIGKILL cascading across replicas.
get "/live", MicrowavepropWeb.HealthController, :live, log: false
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# Prometheus scrape endpoint. No pipeline so there's no session /
# CSRF overhead — the intent is a dedicated external Prometheus
# server polling every 15-30s. If `PROMETHEUS_AUTH_TOKEN` is set
# (runtime.exs), the endpoint requires a matching bearer token;
# otherwise it's unauthenticated and expected to be restricted at
# the ingress / Cloudflare Access layer.
forward "/metrics", MicrowavepropWeb.MetricsPlug
# API discovery (RFC 9727 / RFC 9264). These are JSON-only, no pipeline,
# no session — agents and crawlers fetch them without auth.
get "/.well-known/api-catalog", MicrowavepropWeb.ApiCatalogController, :catalog
get "/openapi.json", MicrowavepropWeb.ApiCatalogController, :openapi
# Agent Skills Discovery (v0.2.0). Index + individual skill documents.
get "/.well-known/agent-skills/index.json", MicrowavepropWeb.AgentSkillsController, :index
get "/.well-known/agent-skills/:name/SKILL.md",
MicrowavepropWeb.AgentSkillsController,
:skill
# Admin-only routes must come first so that literal segments like
# `/beacons/:id/edit` are registered before the public `/beacons/:id`.
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do
live "/beacons/:id/edit", BeaconLive.Form, :edit
live "/status", StatusLive
live "/admin/contact-edits", Admin.ContactEditLive
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
end
end
scope "/", MicrowavepropWeb do
pipe_through :browser
# Plain controller routes — outside live_session because they send raw
# responses (JSON, YAML, markdown) or render static HTML directly and
# don't need the on_mount hooks that set up LiveView session assigns.
get "/", PageController, :home
get "/api/contacts/map", ContactMapController, :show
get "/weather/cells", WeatherTileController, :cells
get "/scores/cells", ScoresController, :cells
get "/docs/api/openapi.yaml", ApiDocsController, :openapi_yaml
get "/docs/api/README.md", ApiDocsController, :readme_markdown
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/docs/api", ApiDocsLive
live "/submit", SubmitLive
live "/imports/:id", ImportLive
live "/map", MapLive
live "/weather", WeatherMapLive
live "/weather-ca", WeatherCaMapLive
live "/contacts", ContactLive.Index
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
live "/path", PathLive
live "/eme", EmeLive
live "/skewt", SkewtLive
live "/rover", RoverLive
live "/rover-locations", RoverLocationsLive
live "/rover-locations/map", RoverLocationsLive.Map
live "/rover-locations/:id", RoverLocationsLive.Show
live "/rover-planning", RoverPlanningLive
live "/rover-planning/new", RoverPlanningLive.Form, :new
live "/rover-planning/:id", RoverPlanningLive.Show
live "/rover-planning/:id/edit", RoverPlanningLive.Form, :edit
live "/rover-planning/:id/paths/:path_id", RoverPlanningLive.PathShow
live "/algo", AlgoLive
live "/about", AboutLive
live "/privacy", PrivacyLive
# Beacons: public read + submit (pending admin approval)
live "/beacons", BeaconLive.Index, :index
live "/beacons/new", BeaconLive.Form, :new
live "/beacons/:id", BeaconLive.Show, :show
# Public per-user profile (contributions: contacts + beacons submitted)
live "/u/:callsign", UserProfileLive
end
# Redirect old /qsos routes
get "/qsos", PageController, :redirect_contacts
get "/qsos/:id", PageController, :redirect_contact
end
# /api/v1 — versioned public REST API. See docs/api/README.md and
# docs/api/openapi.yaml for the full reference.
scope "/api/v1", V1 do
pipe_through :api_v1_login
post "/auth/tokens", AuthController, :create
end
scope "/api/v1", V1 do
pipe_through :api_v1_public
get "/contacts", ContactController, :index
get "/contacts/:id", ContactController, :show
get "/beacons", BeaconController, :index
get "/beacons/:id", BeaconController, :show
get "/profiles/:callsign", ProfileController, :show
get "/scores/bands", ScoreController, :bands
get "/scores", ScoreController, :show
get "/forecast", ScoreController, :forecast
end
scope "/api/v1", V1 do
pipe_through :api_v1_authed
get "/me", MeController, :show
patch "/me", MeController, :update
get "/me/contacts", MeController, :contacts
get "/me/beacons", MeController, :beacons
get "/me/api-tokens", MeController, :list_tokens
delete "/me/api-tokens/:id", MeController, :revoke_token
get "/me/beacon-monitors", MeController, :list_monitors
post "/me/beacon-monitors", MeController, :create_monitor
delete "/me/beacon-monitors/:id", MeController, :delete_monitor
post "/contacts", ContactController, :create
post "/beacons", BeaconController, :create
end
# propmonitor uploads. Separate pipeline because the Bearer here is a
# `BeaconMonitor` token, not a user API token.
scope "/api/v1", V1 do
pipe_through :api_v1_monitor
post "/beacon-monitor/measurements", BeaconMonitorMeasurementController, :create
end
scope "/" do
pipe_through [:browser, :require_authenticated_user]
live_dashboard "/admin/dashboard",
metrics: MicrowavepropWeb.Telemetry,
ecto_repos: [Microwaveprop.Repo],
ecto_psql_extras_options: [long_running_queries: [threshold: "200 milliseconds"]],
on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}]
oban_dashboard("/admin/oban", on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}])
end
# Enable Swoosh mailbox preview in development
if Application.compile_env(:microwaveprop, :dev_routes) do
scope "/dev" do
pipe_through :browser
forward "/mailbox", Plug.Swoosh.MailboxPreview
end
end
## Authentication routes
scope "/", MicrowavepropWeb do
pipe_through [:browser, :redirect_if_user_is_authenticated]
get "/users/register", UserRegistrationController, :new
post "/users/register", UserRegistrationController, :create
get "/users/reset-password", UserResetPasswordController, :new
post "/users/reset-password", UserResetPasswordController, :create
get "/users/reset-password/:token", UserResetPasswordController, :edit
put "/users/reset-password/:token", UserResetPasswordController, :update
end
scope "/", MicrowavepropWeb do
pipe_through [:browser, :require_authenticated_user]
get "/users/settings", UserSettingsController, :edit
put "/users/settings", UserSettingsController, :update
get "/users/settings/confirm-email/:token", UserSettingsController, :confirm_email
post "/users/beacon-monitors", BeaconMonitorController, :create
delete "/users/beacon-monitors/:id", BeaconMonitorController, :delete
post "/users/api-tokens", ApiTokenController, :create
delete "/users/api-tokens/:id", ApiTokenController, :delete
end
scope "/", MicrowavepropWeb do
pipe_through [:browser]
get "/users/log-in", UserSessionController, :new
get "/users/confirm/:token", UserSessionController, :confirm
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
end