- 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
379 lines
14 KiB
Elixir
379 lines
14 KiB
Elixir
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 10–241 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
|