diff --git a/lib/hexdocs/file_rewriter.ex b/lib/hexdocs/file_rewriter.ex index 3e65054..55b235c 100644 --- a/lib/hexdocs/file_rewriter.ex +++ b/lib/hexdocs/file_rewriter.ex @@ -27,7 +27,7 @@ defmodule Hexdocs.FileRewriter do if String.ends_with?(path, ".html") do Regex.replace(@canonical_tag_re, content, fn tag -> Regex.replace(@hexdocs_link_re, tag, fn _match, package -> - "https://#{Hexdocs.Utils.package_to_subdomain(package)}.hexdocs.pm" + "https://#{Hexdocs.Utils.name_to_subdomain(package)}.hexdocs.pm" end) end) else diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex index fc4f7d1..3f182b5 100644 --- a/lib/hexdocs/plug.ex +++ b/lib/hexdocs/plug.ex @@ -75,31 +75,41 @@ defmodule Hexdocs.Plug do send_resp(conn, 400, "") {:ok, subdomain} -> - cond do - # OAuth callback - exchange code for tokens - conn.request_path == "/oauth/callback" -> - handle_oauth_callback(conn, subdomain) - - # OAuth access token in session - access_token = get_session(conn, "access_token") -> - try_serve_page_oauth(conn, subdomain, access_token) - - true -> - redirect_oauth(conn, subdomain) + if String.contains?(subdomain, "_") do + redirect_to_subdomain(conn, subdomain) + else + organization = Hexdocs.Utils.subdomain_to_name(subdomain) + + cond do + # OAuth callback - exchange code for tokens + conn.request_path == "/oauth/callback" -> + handle_oauth_callback(conn, organization) + + # OAuth access token in session + access_token = get_session(conn, "access_token") -> + try_serve_page_oauth(conn, organization, access_token) + + true -> + redirect_oauth(conn, organization) + end end end end end defp redirect_to_hexpm(conn) do - url = Application.get_env(:hexdocs, :hexpm_url) - html = Plug.HTML.html_escape(url) - body = "
You are being redirected." + send_redirect(conn, 301, Application.get_env(:hexdocs, :hexpm_url)) + end - conn - |> put_resp_header("location", url) - |> put_resp_header("content-type", "text/html") - |> send_resp(301, body) + defp redirect_to_subdomain(conn, subdomain) do + scheme = Application.get_env(:hexdocs, :scheme) + host = Application.get_env(:hexdocs, :private_host) + query = if conn.query_string in [nil, ""], do: "", else: "?" <> conn.query_string + + url = + "#{scheme}://#{Hexdocs.Utils.name_to_subdomain(subdomain)}.#{host}#{conn.request_path}#{query}" + + send_redirect(conn, 301, url) end defp redirect_oauth(conn, organization) do @@ -129,7 +139,7 @@ defmodule Hexdocs.Plug do defp build_oauth_redirect_uri(_conn, organization) do scheme = Application.get_env(:hexdocs, :scheme) host = Application.get_env(:hexdocs, :private_host) - "#{scheme}://#{organization}.#{host}/oauth/callback" + "#{scheme}://#{Hexdocs.Utils.name_to_subdomain(organization)}.#{host}/oauth/callback" end defp handle_oauth_callback(conn, organization) do @@ -398,12 +408,16 @@ defmodule Hexdocs.Plug do defp safe_return_path(_), do: "/" defp redirect(conn, url) do + send_redirect(conn, 302, url) + end + + defp send_redirect(conn, status, url) do 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(302, body) + |> send_resp(status, body) end end diff --git a/lib/hexdocs/utils.ex b/lib/hexdocs/utils.ex index c4e33dd..8a51a81 100644 --- a/lib/hexdocs/utils.ex +++ b/lib/hexdocs/utils.ex @@ -9,20 +9,23 @@ 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_to_subdomain(package)}.#{host}#{path}") + URI.encode("#{scheme}://#{name_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" - URI.encode("#{scheme}://#{repository}.#{host}/#{package}#{path}") + URI.encode("#{scheme}://#{name_to_subdomain(repository)}.#{host}/#{package}#{path}") 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, "_", "-") + # Hex package and organization names allow underscores (packages + # `^[a-z][a-z0-9_]*$`, orgs `^[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 subdomain. For public + # hexdocs.pm packages the Fastly Compute subdomain handler reverses the + # mapping; for hexorgs.pm orgs `subdomain_to_name/1` reverses it here. + def name_to_subdomain(name), do: String.replace(name, "_", "-") + + def subdomain_to_name(subdomain), do: String.replace(subdomain, "-", "_") def hexdocs_apex_url(path) do "/" <> _ = path diff --git a/test/hexdocs/plug_test.exs b/test/hexdocs/plug_test.exs index d5c09cc..7098706 100644 --- a/test/hexdocs/plug_test.exs +++ b/test/hexdocs/plug_test.exs @@ -353,6 +353,50 @@ defmodule Hexdocs.PlugTest do end end + describe "hyphenated org subdomains" do + test "redirects an underscore subdomain to the hyphenated host preserving path and query" do + conn = conn(:get, "http://foo_bar.localhost:5002/pkg/1.0.0/index.html?q=1") |> call() + assert conn.status == 301 + [location] = get_resp_header(conn, "location") + assert location == "http://foo-bar.localhost/pkg/1.0.0/index.html?q=1" + end + + test "OAuth scope and redirect_uri use the underscored org name for a hyphenated subdomain" do + conn = conn(:get, "http://foo-bar.localhost:5002/pkg") |> call() + assert conn.status == 302 + + [location] = get_resp_header(conn, "location") + query = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query() + + assert query["scope"] == "docs:foo_bar" + assert query["redirect_uri"] == "http://foo-bar.localhost/oauth/callback" + end + + test "serves from the underscored bucket key for a hyphenated subdomain", %{test: test} do + Mox.expect(HexpmMock, :verify_key, fn _token, organization -> + assert organization == "foo_bar" + :ok + end) + + now = NaiveDateTime.utc_now() + expires_at = NaiveDateTime.add(now, 1800, :second) + Store.put!(@bucket, "foo_bar/#{test}/index.html", "body") + + conn = + conn(:get, "http://foo-bar.localhost:5002/#{test}/index.html") + |> init_test_session(%{ + "access_token" => "eyJhbGciOiJFUzI1NiJ9.test", + "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh", + "token_expires_at" => expires_at, + "token_created_at" => now + }) + |> call() + + assert conn.status == 200 + assert conn.resp_body == "body" + end + end + test "sets security headers" do conn = conn(:get, "http://localhost:5002/foo") |> call() diff --git a/test/hexdocs/utils_test.exs b/test/hexdocs/utils_test.exs new file mode 100644 index 0000000..7ec19ac --- /dev/null +++ b/test/hexdocs/utils_test.exs @@ -0,0 +1,33 @@ +defmodule Hexdocs.UtilsTest do + use ExUnit.Case, async: true + + alias Hexdocs.Utils + + describe "hexdocs_url/3 for org repositories" do + test "maps underscores in the org name to hyphens in the subdomain" do + assert Utils.hexdocs_url("acme_corp", "foo", "/1.0.0") == + "http://acme-corp.localhost/foo/1.0.0" + end + + test "leaves org names without underscores untouched" do + assert Utils.hexdocs_url("acme", "foo", "/1.0.0") == + "http://acme.localhost/foo/1.0.0" + end + end + + describe "name_to_subdomain/1 and subdomain_to_name/1" do + test "name_to_subdomain maps underscores to hyphens" do + assert Utils.name_to_subdomain("foo_bar") == "foo-bar" + end + + test "subdomain_to_name maps hyphens to underscores" do + assert Utils.subdomain_to_name("foo-bar") == "foo_bar" + end + + test "round-trips" do + for name <- ~w(foo foo_bar a_b_c plug) do + assert name |> Utils.name_to_subdomain() |> Utils.subdomain_to_name() == name + end + end + end +end