From c6eea9a793949d9e5fd86ed537f363740ce5e047 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Thu, 28 May 2026 22:32:06 +0200 Subject: [PATCH 1/2] Switch to per-package subdomains and drop public-host redirect `Hexdocs.Utils.hexdocs_url/3` now emits `https://PACKAGE.hexdocs.pm/path` for the hexpm repo. The sitemap keeps apex URLs via a new `hexdocs_apex_url/1` helper so Googlebot still discovers packages via the apex 301 chain. The Plug-level `*.hexdocs.pm -> *.hexorgs.pm` redirect arm is removed. The Fastly Compute service will handle that redirect once the DNS flip lands; this app now only serves the `*.hexorgs.pm` (private/org) path. --- lib/hexdocs/bucket.ex | 2 +- lib/hexdocs/package_sitemap.ex | 2 +- lib/hexdocs/plug.ex | 19 ------------------- lib/hexdocs/utils.ex | 13 ++++++++++--- test/hexdocs/plug_test.exs | 21 ++++++++++----------- test/hexdocs/queue_test.exs | 4 ++-- 6 files changed, 24 insertions(+), 37 deletions(-) diff --git a/lib/hexdocs/bucket.ex b/lib/hexdocs/bucket.ex index 61f0388..46f747b 100644 --- a/lib/hexdocs/bucket.ex +++ b/lib/hexdocs/bucket.ex @@ -117,7 +117,7 @@ defmodule Hexdocs.Bucket do for version <- versions do map = %{ version: "v#{version}", - url: Hexdocs.Utils.hexdocs_url(repository, "/#{package}/#{version}") + url: Hexdocs.Utils.hexdocs_url(repository, package, "/#{version}") } map = if latest_version == version, do: Map.put(map, :latest, true), else: map diff --git a/lib/hexdocs/package_sitemap.ex b/lib/hexdocs/package_sitemap.ex index ea2eac6..337a2a7 100644 --- a/lib/hexdocs/package_sitemap.ex +++ b/lib/hexdocs/package_sitemap.ex @@ -9,7 +9,7 @@ defmodule Hexdocs.PackageSitemap do xsi:schemaLocation="http://www.sitemaps.org/schemas/sitemap/0.9 http://www.sitemaps.org/schemas/sitemap/0.9/sitemap.xsd"> <%= for page <- pages do %> - <%= Hexdocs.Utils.hexdocs_url("hexpm", "/#{package_name}/#{page}") %> + <%= Hexdocs.Utils.hexdocs_apex_url("/#{package_name}/#{page}") %> <%= format_datetime updated_at %> daily 0.8 diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index 501ea1c..1f4954a 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -71,9 +71,6 @@ defmodule Hexdocs.Plug do :error -> send_resp(conn, 400, "") - {:redirect, subdomain} -> - redirect_to_private_host(conn, subdomain) - {:ok, subdomain} -> cond do # OAuth callback - exchange code for tokens @@ -90,20 +87,6 @@ defmodule Hexdocs.Plug do end end - defp redirect_to_private_host(conn, subdomain) do - scheme = Application.get_env(:hexdocs, :scheme) - host = Application.get_env(:hexdocs, :private_host) - url = "#{scheme}://#{subdomain}.#{host}#{conn.request_path}" - - html = Plug.HTML.html_escape(url) - body = "You are being redirected." - - conn - |> put_resp_header("location", url) - |> put_resp_header("content-type", "text/html") - |> send_resp(301, body) - end - defp redirect_oauth(conn, organization) do code_verifier = Hexdocs.OAuth.generate_code_verifier() code_challenge = Hexdocs.OAuth.generate_code_challenge(code_verifier) @@ -285,12 +268,10 @@ defmodule Hexdocs.Plug do end defp subdomain(host) do - public_host = Application.get_env(:hexdocs, :host) private_host = Application.get_env(:hexdocs, :private_host) case String.split(host, ".", parts: 2) do [subdomain, ^private_host] -> {:ok, subdomain} - [subdomain, ^public_host] -> {:redirect, subdomain} _ -> :error end end diff --git a/lib/hexdocs/utils.ex b/lib/hexdocs/utils.ex index ff9fb5b..6ea45ce 100644 --- a/lib/hexdocs/utils.ex +++ b/lib/hexdocs/utils.ex @@ -3,20 +3,27 @@ defmodule Hexdocs.Utils do @special_package_names Map.keys(Application.compile_env!(:hexdocs, :special_packages)) - def hexdocs_url(repository, path) do + def hexdocs_url(repository, package, path) do "/" <> _ = path if repository == "hexpm" do host = Application.get_env(:hexdocs, :host) scheme = if host == "hexdocs.pm", do: "https", else: "http" - URI.encode("#{scheme}://#{host}#{path}") + URI.encode("#{scheme}://#{package}.#{host}#{path}") else host = Application.get_env(:hexdocs, :private_host) scheme = if host in ["hexdocs.pm", "hexorgs.pm"], do: "https", else: "http" - URI.encode("#{scheme}://#{repository}.#{host}#{path}") + URI.encode("#{scheme}://#{repository}.#{host}/#{package}#{path}") end end + def hexdocs_apex_url(path) do + "/" <> _ = path + host = Application.get_env(:hexdocs, :host) + scheme = if host == "hexdocs.pm", do: "https", else: "http" + URI.encode("#{scheme}://#{host}#{path}") + end + def latest_version(versions) do Enum.find(versions, &(&1.pre == [])) || List.first(versions) end diff --git a/test/hexdocs/plug_test.exs b/test/hexdocs/plug_test.exs index b3717ca..c8206cd 100644 --- a/test/hexdocs/plug_test.exs +++ b/test/hexdocs/plug_test.exs @@ -309,26 +309,16 @@ defmodule Hexdocs.PlugTest do end end - describe "redirect from public host to private host" do + describe "host handling" do setup do - original_host = Application.get_env(:hexdocs, :host) original_private_host = Application.get_env(:hexdocs, :private_host) - Application.put_env(:hexdocs, :host, "hexdocs.test") Application.put_env(:hexdocs, :private_host, "hexorgs.test") on_exit(fn -> - Application.put_env(:hexdocs, :host, original_host) Application.put_env(:hexdocs, :private_host, original_private_host) end) end - test "301 redirects from *.hexdocs.test to *.hexorgs.test" do - conn = conn(:get, "http://myorg.hexdocs.test:5002/my_package/index.html") |> call() - assert conn.status == 301 - [location] = get_resp_header(conn, "location") - assert location == "http://myorg.hexorgs.test/my_package/index.html" - end - test "serves docs on private host" do conn = conn(:get, "http://myorg.hexorgs.test:5002/foo") |> call() assert conn.status == 302 @@ -341,6 +331,15 @@ defmodule Hexdocs.PlugTest do conn = conn(:get, "http://other.example.com:5002/foo") |> call() assert conn.status == 400 end + + test "returns 400 for *.hexdocs.pm hosts (handled by Fastly)" do + original_host = Application.get_env(:hexdocs, :host) + Application.put_env(:hexdocs, :host, "hexdocs.test") + on_exit(fn -> Application.put_env(:hexdocs, :host, original_host) end) + + conn = conn(:get, "http://phoenix.hexdocs.test:5002/index.html") |> call() + assert conn.status == 400 + end end test "sets security headers" do diff --git a/test/hexdocs/queue_test.exs b/test/hexdocs/queue_test.exs index 9e0969f..844b742 100644 --- a/test/hexdocs/queue_test.exs +++ b/test/hexdocs/queue_test.exs @@ -241,12 +241,12 @@ defmodule Hexdocs.QueueTest do assert JSON.decode!(versions_json) == [ %{ - "url" => "http://localhost/#{URI.encode(Atom.to_string(test))}/3.0.0", + "url" => "http://#{URI.encode(Atom.to_string(test))}.localhost/3.0.0", "version" => "v3.0.0", "latest" => true }, %{ - "url" => "http://localhost/#{URI.encode(Atom.to_string(test))}/1.0.0", + "url" => "http://#{URI.encode(Atom.to_string(test))}.localhost/1.0.0", "version" => "v1.0.0", "retired" => true } From 2c71abdeaec8b0a16a5270517f11a8e77f9783ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Fri, 29 May 2026 11:28:48 +0200 Subject: [PATCH 2/2] Map underscore to hyphen in hexpm-repo subdomain URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hex package names allow underscores (^[a-z][a-z0-9_]*$). RFC 1123 hostname labels and RFC 6125 wildcard SAN matching don't, and Fastly enforces strict SAN matching at the HTTP edge — phoenix_live_view.hexdocs.pm returns 421 'Misdirected Request' even though the wildcard cert technically covers *.hexdocs.pm. Hexdocs.Utils.hexdocs_url/3 for the hexpm-repo branch now maps _ -> - when building the subdomain. The mapping is reversed in the Fastly Compute subdomain handler before building the GCS bucket key, so canonical hex names continue to be the storage key. The org-repo branch is unchanged: org subdomains live under hexorgs.pm, which still A-records to GKE and doesn't enforce strict SAN matching. Queue test updated for the new URL shape. --- lib/hexdocs/utils.ex | 9 ++++++++- test/hexdocs/queue_test.exs | 6 ++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/lib/hexdocs/utils.ex b/lib/hexdocs/utils.ex index 6ea45ce..c4e33dd 100644 --- a/lib/hexdocs/utils.ex +++ b/lib/hexdocs/utils.ex @@ -9,7 +9,7 @@ defmodule Hexdocs.Utils do if repository == "hexpm" do host = Application.get_env(:hexdocs, :host) scheme = if host == "hexdocs.pm", do: "https", else: "http" - URI.encode("#{scheme}://#{package}.#{host}#{path}") + URI.encode("#{scheme}://#{package_to_subdomain(package)}.#{host}#{path}") else host = Application.get_env(:hexdocs, :private_host) scheme = if host in ["hexdocs.pm", "hexorgs.pm"], do: "https", else: "http" @@ -17,6 +17,13 @@ defmodule Hexdocs.Utils do end end + # Hex package names allow underscores (`^[a-z][a-z0-9_]*$`), but RFC 1123 + # hostname labels and RFC 6125 wildcard SAN matching don't, and Fastly + # enforces strict SAN matching at the HTTP edge. Map `_` -> `-` for the + # public hexdocs.pm subdomain. The mapping is reversed in the Fastly + # Compute subdomain handler before the GCS bucket key is built. + def package_to_subdomain(name), do: String.replace(name, "_", "-") + def hexdocs_apex_url(path) do "/" <> _ = path host = Application.get_env(:hexdocs, :host) diff --git a/test/hexdocs/queue_test.exs b/test/hexdocs/queue_test.exs index 844b742..9f9b168 100644 --- a/test/hexdocs/queue_test.exs +++ b/test/hexdocs/queue_test.exs @@ -239,14 +239,16 @@ defmodule Hexdocs.QueueTest do ["var versionNodes = " <> versions_json, "var searchNodes = " <> search_json] = String.split(docs_config, [";", "\n"], trim: true) + subdomain = URI.encode(String.replace(Atom.to_string(test), "_", "-")) + assert JSON.decode!(versions_json) == [ %{ - "url" => "http://#{URI.encode(Atom.to_string(test))}.localhost/3.0.0", + "url" => "http://#{subdomain}.localhost/3.0.0", "version" => "v3.0.0", "latest" => true }, %{ - "url" => "http://#{URI.encode(Atom.to_string(test))}.localhost/1.0.0", + "url" => "http://#{subdomain}.localhost/1.0.0", "version" => "v1.0.0", "retired" => true }