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.
277 lines
10 KiB
Elixir
277 lines
10 KiB
Elixir
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 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.
|
||
- [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
|