Skip to content

Commit 093a5f4

Browse files
authored
Serve private docs on hyphenated org subdomains (#121)
Org names allow underscores, but RFC 1123 hostname labels and Fastly's strict SAN matching don't, so map `_` -> `-` for `*.hexorgs.pm` subdomains, mirroring the public package side. The hexorgs.pm path is served by this app rather than Fastly, so the reverse `-` -> `_` mapping and the 301 from the old underscore host both live in the plug. - Utils: add name_to_subdomain/1 and subdomain_to_name/1 (replacing package_to_subdomain/1); hexdocs_url/3 now hyphenates the org subdomain. - Plug: 301-redirect underscore hosts to the hyphenated host (preserving path and query); reverse-map the subdomain to the org name for the OAuth scope, key verification and bucket key; build the OAuth redirect_uri with the hyphenated host.
1 parent 0b5597e commit 093a5f4

5 files changed

Lines changed: 123 additions & 29 deletions

File tree

lib/hexdocs/file_rewriter.ex

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ defmodule Hexdocs.FileRewriter do
2727
if String.ends_with?(path, ".html") do
2828
Regex.replace(@canonical_tag_re, content, fn tag ->
2929
Regex.replace(@hexdocs_link_re, tag, fn _match, package ->
30-
"https://#{Hexdocs.Utils.package_to_subdomain(package)}.hexdocs.pm"
30+
"https://#{Hexdocs.Utils.name_to_subdomain(package)}.hexdocs.pm"
3131
end)
3232
end)
3333
else

lib/hexdocs/plug.ex

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -75,31 +75,41 @@ defmodule Hexdocs.Plug do
7575
send_resp(conn, 400, "")
7676

7777
{:ok, subdomain} ->
78-
cond do
79-
# OAuth callback - exchange code for tokens
80-
conn.request_path == "/oauth/callback" ->
81-
handle_oauth_callback(conn, subdomain)
82-
83-
# OAuth access token in session
84-
access_token = get_session(conn, "access_token") ->
85-
try_serve_page_oauth(conn, subdomain, access_token)
86-
87-
true ->
88-
redirect_oauth(conn, subdomain)
78+
if String.contains?(subdomain, "_") do
79+
redirect_to_subdomain(conn, subdomain)
80+
else
81+
organization = Hexdocs.Utils.subdomain_to_name(subdomain)
82+
83+
cond do
84+
# OAuth callback - exchange code for tokens
85+
conn.request_path == "/oauth/callback" ->
86+
handle_oauth_callback(conn, organization)
87+
88+
# OAuth access token in session
89+
access_token = get_session(conn, "access_token") ->
90+
try_serve_page_oauth(conn, organization, access_token)
91+
92+
true ->
93+
redirect_oauth(conn, organization)
94+
end
8995
end
9096
end
9197
end
9298
end
9399

94100
defp redirect_to_hexpm(conn) do
95-
url = Application.get_env(:hexdocs, :hexpm_url)
96-
html = Plug.HTML.html_escape(url)
97-
body = "<html><body>You are being <a href=\"#{html}\">redirected</a>.</body></html>"
101+
send_redirect(conn, 301, Application.get_env(:hexdocs, :hexpm_url))
102+
end
98103

99-
conn
100-
|> put_resp_header("location", url)
101-
|> put_resp_header("content-type", "text/html")
102-
|> send_resp(301, body)
104+
defp redirect_to_subdomain(conn, subdomain) do
105+
scheme = Application.get_env(:hexdocs, :scheme)
106+
host = Application.get_env(:hexdocs, :private_host)
107+
query = if conn.query_string in [nil, ""], do: "", else: "?" <> conn.query_string
108+
109+
url =
110+
"#{scheme}://#{Hexdocs.Utils.name_to_subdomain(subdomain)}.#{host}#{conn.request_path}#{query}"
111+
112+
send_redirect(conn, 301, url)
103113
end
104114

105115
defp redirect_oauth(conn, organization) do
@@ -129,7 +139,7 @@ defmodule Hexdocs.Plug do
129139
defp build_oauth_redirect_uri(_conn, organization) do
130140
scheme = Application.get_env(:hexdocs, :scheme)
131141
host = Application.get_env(:hexdocs, :private_host)
132-
"#{scheme}://#{organization}.#{host}/oauth/callback"
142+
"#{scheme}://#{Hexdocs.Utils.name_to_subdomain(organization)}.#{host}/oauth/callback"
133143
end
134144

135145
defp handle_oauth_callback(conn, organization) do
@@ -398,12 +408,16 @@ defmodule Hexdocs.Plug do
398408
defp safe_return_path(_), do: "/"
399409

400410
defp redirect(conn, url) do
411+
send_redirect(conn, 302, url)
412+
end
413+
414+
defp send_redirect(conn, status, url) do
401415
html = Plug.HTML.html_escape(url)
402416
body = "<html><body>You are being <a href=\"#{html}\">redirected</a>.</body></html>"
403417

404418
conn
405419
|> put_resp_header("location", url)
406420
|> put_resp_header("content-type", "text/html")
407-
|> send_resp(302, body)
421+
|> send_resp(status, body)
408422
end
409423
end

lib/hexdocs/utils.ex

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,23 @@ defmodule Hexdocs.Utils do
99
if repository == "hexpm" do
1010
host = Application.get_env(:hexdocs, :host)
1111
scheme = if host == "hexdocs.pm", do: "https", else: "http"
12-
URI.encode("#{scheme}://#{package_to_subdomain(package)}.#{host}#{path}")
12+
URI.encode("#{scheme}://#{name_to_subdomain(package)}.#{host}#{path}")
1313
else
1414
host = Application.get_env(:hexdocs, :private_host)
1515
scheme = if host in ["hexdocs.pm", "hexorgs.pm"], do: "https", else: "http"
16-
URI.encode("#{scheme}://#{repository}.#{host}/#{package}#{path}")
16+
URI.encode("#{scheme}://#{name_to_subdomain(repository)}.#{host}/#{package}#{path}")
1717
end
1818
end
1919

20-
# Hex package names allow underscores (`^[a-z][a-z0-9_]*$`), but RFC 1123
21-
# hostname labels and RFC 6125 wildcard SAN matching don't, and Fastly
22-
# enforces strict SAN matching at the HTTP edge. Map `_` -> `-` for the
23-
# public hexdocs.pm subdomain. The mapping is reversed in the Fastly
24-
# Compute subdomain handler before the GCS bucket key is built.
25-
def package_to_subdomain(name), do: String.replace(name, "_", "-")
20+
# Hex package and organization names allow underscores (packages
21+
# `^[a-z][a-z0-9_]*$`, orgs `^[a-z0-9_]+$`), but RFC 1123 hostname labels
22+
# and RFC 6125 wildcard SAN matching don't, and Fastly enforces strict SAN
23+
# matching at the HTTP edge. Map `_` -> `-` for the subdomain. For public
24+
# hexdocs.pm packages the Fastly Compute subdomain handler reverses the
25+
# mapping; for hexorgs.pm orgs `subdomain_to_name/1` reverses it here.
26+
def name_to_subdomain(name), do: String.replace(name, "_", "-")
27+
28+
def subdomain_to_name(subdomain), do: String.replace(subdomain, "-", "_")
2629

2730
def hexdocs_apex_url(path) do
2831
"/" <> _ = path

test/hexdocs/plug_test.exs

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,50 @@ defmodule Hexdocs.PlugTest do
353353
end
354354
end
355355

356+
describe "hyphenated org subdomains" do
357+
test "redirects an underscore subdomain to the hyphenated host preserving path and query" do
358+
conn = conn(:get, "http://foo_bar.localhost:5002/pkg/1.0.0/index.html?q=1") |> call()
359+
assert conn.status == 301
360+
[location] = get_resp_header(conn, "location")
361+
assert location == "http://foo-bar.localhost/pkg/1.0.0/index.html?q=1"
362+
end
363+
364+
test "OAuth scope and redirect_uri use the underscored org name for a hyphenated subdomain" do
365+
conn = conn(:get, "http://foo-bar.localhost:5002/pkg") |> call()
366+
assert conn.status == 302
367+
368+
[location] = get_resp_header(conn, "location")
369+
query = location |> URI.parse() |> Map.fetch!(:query) |> URI.decode_query()
370+
371+
assert query["scope"] == "docs:foo_bar"
372+
assert query["redirect_uri"] == "http://foo-bar.localhost/oauth/callback"
373+
end
374+
375+
test "serves from the underscored bucket key for a hyphenated subdomain", %{test: test} do
376+
Mox.expect(HexpmMock, :verify_key, fn _token, organization ->
377+
assert organization == "foo_bar"
378+
:ok
379+
end)
380+
381+
now = NaiveDateTime.utc_now()
382+
expires_at = NaiveDateTime.add(now, 1800, :second)
383+
Store.put!(@bucket, "foo_bar/#{test}/index.html", "body")
384+
385+
conn =
386+
conn(:get, "http://foo-bar.localhost:5002/#{test}/index.html")
387+
|> init_test_session(%{
388+
"access_token" => "eyJhbGciOiJFUzI1NiJ9.test",
389+
"refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh",
390+
"token_expires_at" => expires_at,
391+
"token_created_at" => now
392+
})
393+
|> call()
394+
395+
assert conn.status == 200
396+
assert conn.resp_body == "body"
397+
end
398+
end
399+
356400
test "sets security headers" do
357401
conn = conn(:get, "http://localhost:5002/foo") |> call()
358402

test/hexdocs/utils_test.exs

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
defmodule Hexdocs.UtilsTest do
2+
use ExUnit.Case, async: true
3+
4+
alias Hexdocs.Utils
5+
6+
describe "hexdocs_url/3 for org repositories" do
7+
test "maps underscores in the org name to hyphens in the subdomain" do
8+
assert Utils.hexdocs_url("acme_corp", "foo", "/1.0.0") ==
9+
"http://acme-corp.localhost/foo/1.0.0"
10+
end
11+
12+
test "leaves org names without underscores untouched" do
13+
assert Utils.hexdocs_url("acme", "foo", "/1.0.0") ==
14+
"http://acme.localhost/foo/1.0.0"
15+
end
16+
end
17+
18+
describe "name_to_subdomain/1 and subdomain_to_name/1" do
19+
test "name_to_subdomain maps underscores to hyphens" do
20+
assert Utils.name_to_subdomain("foo_bar") == "foo-bar"
21+
end
22+
23+
test "subdomain_to_name maps hyphens to underscores" do
24+
assert Utils.subdomain_to_name("foo-bar") == "foo_bar"
25+
end
26+
27+
test "round-trips" do
28+
for name <- ~w(foo foo_bar a_b_c plug) do
29+
assert name |> Utils.name_to_subdomain() |> Utils.subdomain_to_name() == name
30+
end
31+
end
32+
end
33+
end

0 commit comments

Comments
 (0)