From 4854157ee98b95274e73d2e2c2e9dffda41033cc Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Fri, 17 Apr 2026 13:31:20 -0500 Subject: [PATCH] feat: add sitemap.xml, api-catalog, and agent-discovery Link headers --- lib/aprsme_web/controllers/page_controller.ex | 51 +++++++++++++++++++ lib/aprsme_web/plugs/agent_discovery_links.ex | 17 +++++++ lib/aprsme_web/router.ex | 7 +++ priv/static/robots.txt | 2 + 4 files changed, 77 insertions(+) create mode 100644 lib/aprsme_web/plugs/agent_discovery_links.ex diff --git a/lib/aprsme_web/controllers/page_controller.ex b/lib/aprsme_web/controllers/page_controller.ex index ee2d644..446be6c 100644 --- a/lib/aprsme_web/controllers/page_controller.ex +++ b/lib/aprsme_web/controllers/page_controller.ex @@ -60,6 +60,57 @@ defmodule AprsmeWeb.PageController do end end + @sitemap_paths ~w(/ /about /api /status /packets) + + def sitemap(conn, _params) do + base_url = AprsmeWeb.Endpoint.url() + + urls = + Enum.map(@sitemap_paths, fn path -> + " #{base_url}#{path}" + end) + + xml = """ + + + #{Enum.join(urls, "\n")} + + """ + + conn + |> put_resp_content_type("application/xml") + |> send_resp(200, xml) + end + + def api_catalog(conn, _params) do + base_url = AprsmeWeb.Endpoint.url() + ws_url = String.replace(base_url, ~r/^http/, "ws") + + body = %{ + linkset: [ + %{ + anchor: "#{base_url}/api/v1", + "service-doc": [%{href: "#{base_url}/api", type: "text/html"}], + status: [%{href: "#{base_url}/status.json", type: "application/json"}] + }, + %{ + anchor: "#{ws_url}/mobile/websocket", + "service-doc": [ + %{ + href: "https://github.com/gmcintire/aprs.me/blob/main/docs/mobile-api.md", + type: "text/markdown" + } + ], + status: [%{href: "#{base_url}/status.json", type: "application/json"}] + } + ] + } + + conn + |> put_resp_content_type("application/linkset+json") + |> send_resp(200, Jason.encode!(body)) + end + def ready(conn, _params) do # Simple readiness check without database dependency # This is faster for startup health checks diff --git a/lib/aprsme_web/plugs/agent_discovery_links.ex b/lib/aprsme_web/plugs/agent_discovery_links.ex new file mode 100644 index 0000000..0056d13 --- /dev/null +++ b/lib/aprsme_web/plugs/agent_discovery_links.ex @@ -0,0 +1,17 @@ +defmodule AprsmeWeb.Plugs.AgentDiscoveryLinks do + @moduledoc false + + @behaviour Plug + + import Plug.Conn + + @link_header ~s(; rel="api-catalog", ; rel="service-doc") + + @impl true + def init(opts), do: opts + + @impl true + def call(conn, _opts) do + put_resp_header(conn, "link", @link_header) + end +end diff --git a/lib/aprsme_web/router.ex b/lib/aprsme_web/router.ex index 79b2c3e..011eba5 100644 --- a/lib/aprsme_web/router.ex +++ b/lib/aprsme_web/router.ex @@ -29,6 +29,7 @@ defmodule AprsmeWeb.Router do plug AprsmeWeb.Plugs.SetLocale plug IPGeolocation plug RateLimiter, scale: 60_000, limit: 200 + plug AprsmeWeb.Plugs.AgentDiscoveryLinks end pipeline :public_api do @@ -71,6 +72,12 @@ defmodule AprsmeWeb.Router do get "/status.json", PageController, :status_json end + # Agent/crawler discovery endpoints + scope "/", AprsmeWeb do + get "/sitemap.xml", PageController, :sitemap + get "/.well-known/api-catalog", PageController, :api_catalog + end + ## Authentication routes scope "/", AprsmeWeb do diff --git a/priv/static/robots.txt b/priv/static/robots.txt index 26e06b5..8360d08 100644 --- a/priv/static/robots.txt +++ b/priv/static/robots.txt @@ -3,3 +3,5 @@ # To ban all spiders from the entire site uncomment the next two lines: # User-agent: * # Disallow: / + +Sitemap: https://aprs.me/sitemap.xml