prop/lib/microwaveprop_web/router.ex
Graham McIntire 00aad3cb98
feat(auth): self-service password reset
Add a "Forgot your password?" flow off the login page. A 24-hour
reset_password token is emailed on request, the landing page lets the
user pick a new password, and all other tokens for the user are
revoked on success.

The request endpoint returns the same flash regardless of whether the
email matches a user so that attackers can't enumerate accounts.
2026-04-18 08:42:35 -05:00

259 lines
9.1 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 check — no pipeline, minimal overhead
get "/health", MicrowavepropWeb.HealthController, :check, log: false
# 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
live_session :public, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do
live "/submit", SubmitLive
live "/imports/:id", ImportLive
live "/map", MapLive
live "/weather", WeatherMapLive
live "/contacts", ContactLive.Index
live "/contacts/map", ContactMapLive
live "/contacts/:id", ContactLive.Show
live "/path", PathLive
live "/rover", RoverLive
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