Adds a Rover dropdown to the top nav bar and lists the three rover tools in the homepage markdown Primary Resources block.
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 :serve_markdown_if_requested
|
||
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 :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: 30
|
||
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
|
||
|
||
get "/", PageController, :home
|
||
get "/api/contacts/map", ContactMapController, :show
|
||
get "/weather/cells", WeatherTileController, :cells
|
||
get "/scores/cells", ScoresController, :cells
|
||
|
||
# Static API doc artifacts. Outside the live_session so the
|
||
# raw-format endpoints don't get the LiveView root layout.
|
||
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
|