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(; rel="service-doc"), ~s(; rel="about"), ~s(; rel="privacy-policy"), ~s(; 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