Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/hexdocs/file_rewriter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
54 changes: 34 additions & 20 deletions lib/hexdocs/plug.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<html><body>You are being <a href=\"#{html}\">redirected</a>.</body></html>"
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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 = "<html><body>You are being <a href=\"#{html}\">redirected</a>.</body></html>"

conn
|> put_resp_header("location", url)
|> put_resp_header("content-type", "text/html")
|> send_resp(302, body)
|> send_resp(status, body)
end
end
19 changes: 11 additions & 8 deletions lib/hexdocs/utils.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions test/hexdocs/plug_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
33 changes: 33 additions & 0 deletions test/hexdocs/utils_test.exs
Original file line number Diff line number Diff line change
@@ -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