prop/lib/microwaveprop_web/router.ex
Graham McIntire ac3a2517d9
feat(weather): /weather-ca endpoint shows HRDPS-only Canadian data
New LiveView at /weather-ca duplicates the /weather UI but defaults the
viewport to central Canada (55N, -100W, z=4) and renders the map div
with data-source="hrdps". The JS hook reads that attribute and appends
&source=hrdps to every cell fetch; the WeatherTileController routes
those reads through Weather.weather_grid_hrdps_at/2, which only reads
the <vt>.hrdps scalar dir and skips HRRR merging entirely. Timeline is
sourced from ScalarFile.list_valid_times_hrdps/0 so HRDPS-only hours
show up even when no HRRR file exists for the same valid_time.

Also drops "— Unified" from the algo.md H1.
2026-04-30 12:08:48 -05:00

277 lines
10 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
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 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.
- [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
# 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
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
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 "/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
# Other scopes may use custom stacks.
# scope "/api", MicrowavepropWeb do
# pipe_through :api
# 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
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