diff --git a/Dockerfile b/Dockerfile
index 6cac210..5cf20a9 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -30,7 +30,6 @@ RUN mix deps.get
RUN mix deps.compile
# build project
-COPY priv priv
COPY lib lib
RUN mix compile
diff --git a/config/config.exs b/config/config.exs
index 43a18ed..690e0e6 100644
--- a/config/config.exs
+++ b/config/config.exs
@@ -1,13 +1,8 @@
import Config
config :hexdocs,
- scheme: "http",
- port: "4002",
hexpm_url: "http://localhost:4000",
hexpm_secret: "2cd6d09334d4b00a2be4d532342b799b",
- # OAuth client credentials for hexpm integration
- oauth_client_id: "hexdocs",
- oauth_client_secret: "dev_secret_for_testing",
typesense_url: "http://localhost:8108",
typesense_api_key: "hexdocs",
typesense_collection: "hexdocs",
@@ -21,9 +16,6 @@ config :hexdocs,
queue_id: "test",
queue_producer: Broadway.DummyProducer,
queue_concurrency: 1,
- session_key_base: "9doqKjmNsklcWmv1+779E2su++ejdBhnSYgqAiGgwtAPpdVf4ns5eXi4IOZk1Eoi",
- session_signing_salt: "QftsNdJO",
- session_encryption_salt: "QftsNdJO",
host: "localhost",
private_host: "localhost",
gcs_put_debounce: 0,
diff --git a/config/dev.exs b/config/dev.exs
index f804867..a8b569d 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -1,7 +1,6 @@
import Config
config :hexdocs,
- port: "4002",
hexpm_url: "http://localhost:4000",
hexpm_impl: Hexdocs.Hexpm.Impl,
store_impl: Hexdocs.Store.Local,
diff --git a/config/prod.exs b/config/prod.exs
index 8ce97ba..acd2c96 100644
--- a/config/prod.exs
+++ b/config/prod.exs
@@ -1,7 +1,6 @@
import Config
config :hexdocs,
- scheme: "https",
hexpm_impl: Hexdocs.Hexpm.Impl,
store_impl: Hexdocs.Store.Impl,
cdn_impl: Hexdocs.CDN.Fastly,
@@ -23,4 +22,3 @@ config :sentry,
config :sasl, sasl_error_logger: false
config :logger, level: :info
-config :logger, :default_formatter, metadata: [:request_id]
diff --git a/config/runtime.exs b/config/runtime.exs
index 4085174..9e59013 100644
--- a/config/runtime.exs
+++ b/config/runtime.exs
@@ -2,11 +2,8 @@ import Config
if config_env() == :prod do
config :hexdocs,
- port: System.fetch_env!("HEXDOCS_PORT"),
hexpm_url: System.fetch_env!("HEXDOCS_HEXPM_URL"),
hexpm_secret: System.fetch_env!("HEXDOCS_HEXPM_SECRET"),
- oauth_client_id: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_ID"),
- oauth_client_secret: System.fetch_env!("HEXDOCS_OAUTH_CLIENT_SECRET"),
typesense_url: System.fetch_env!("HEXDOCS_TYPESENSE_URL"),
typesense_api_key: System.fetch_env!("HEXDOCS_TYPESENSE_API_KEY"),
typesense_collection: System.fetch_env!("HEXDOCS_TYPESENSE_COLLECTION"),
@@ -14,9 +11,6 @@ if config_env() == :prod do
fastly_hexdocs: System.fetch_env!("HEXDOCS_FASTLY_HEXDOCS"),
fastly_hexdocs_private: System.fetch_env!("HEXDOCS_FASTLY_HEXDOCS_PRIVATE"),
queue_id: System.fetch_env!("HEXDOCS_QUEUE_ID"),
- session_key_base: System.fetch_env!("HEXDOCS_SESSION_KEY_BASE"),
- session_signing_salt: System.fetch_env!("HEXDOCS_SESSION_SIGNING_SALT"),
- session_encryption_salt: System.fetch_env!("HEXDOCS_SESSION_ENCRYPTION_SALT"),
queue_concurrency: String.to_integer(System.fetch_env!("HEXDOCS_QUEUE_CONCURRENCY")),
github_user: System.fetch_env!("HEXDOCS_GITHUB_USER"),
github_token: System.fetch_env!("HEXDOCS_GITHUB_TOKEN"),
diff --git a/config/test.exs b/config/test.exs
index 9345425..4e5eb50 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -1,7 +1,6 @@
import Config
config :hexdocs,
- port: "5002",
hexpm_url: "http://localhost:5000",
hexpm_impl: Hexdocs.HexpmMock,
store_impl: Hexdocs.Store.Local,
diff --git a/lib/hexdocs/application.ex b/lib/hexdocs/application.ex
index d6bdfc2..65c3354 100644
--- a/lib/hexdocs/application.ex
+++ b/lib/hexdocs/application.ex
@@ -1,22 +1,15 @@
defmodule Hexdocs.Application do
use Application
- require Logger
-
def start(_type, _args) do
setup_tmp_dir()
:logger.add_handler(:sentry_handler, Sentry.LoggerHandler, %{})
- port = String.to_integer(Application.get_env(:hexdocs, :port))
- bandit_options = [plug: Hexdocs.Plug, port: port]
- Logger.info("Running Bandit with #{inspect(bandit_options)}")
-
children = [
Hexdocs.TmpDir,
{Task.Supervisor, name: Hexdocs.Tasks},
{Hexdocs.Debouncer, name: Hexdocs.Debouncer},
goth_spec(),
- {Bandit, bandit_options},
Hexdocs.Queue
]
@@ -25,10 +18,10 @@ defmodule Hexdocs.Application do
end
def sentry_before_send(%Sentry.Event{original_exception: exception} = event) do
- cond do
- Plug.Exception.status(exception) < 500 -> nil
- Sentry.DefaultEventFilter.exclude_exception?(exception, event.source) -> nil
- true -> event
+ if Sentry.DefaultEventFilter.exclude_exception?(exception, event.source) do
+ nil
+ else
+ event
end
end
diff --git a/lib/hexdocs/hexpm/hexpm.ex b/lib/hexdocs/hexpm/hexpm.ex
index 05dfccf..5cf206e 100644
--- a/lib/hexdocs/hexpm/hexpm.ex
+++ b/lib/hexdocs/hexpm/hexpm.ex
@@ -1,12 +1,9 @@
defmodule Hexdocs.Hexpm do
- @callback verify_key(key :: String.t(), organization :: String.t()) ::
- :ok | :refresh | {:error, reason :: String.t()}
@callback get_package(repo :: String.t(), package :: String.t()) :: map() | nil
@callback hexdocs_sitemap() :: binary()
defp impl(), do: Application.get_env(:hexdocs, :hexpm_impl)
- def verify_key(key, organization), do: impl().verify_key(key, organization)
def get_package(repo, package), do: impl().get_package(repo, package)
def hexdocs_sitemap(), do: impl().hexdocs_sitemap()
end
diff --git a/lib/hexdocs/hexpm/impl.ex b/lib/hexdocs/hexpm/impl.ex
index 98c4c96..2a53c57 100644
--- a/lib/hexdocs/hexpm/impl.ex
+++ b/lib/hexdocs/hexpm/impl.ex
@@ -1,31 +1,6 @@
defmodule Hexdocs.Hexpm.Impl do
@behaviour Hexdocs.Hexpm
- @refresh_errors [
- "invalid API key",
- "API key revoked",
- "key not authorized for this action"
- ]
-
- def verify_key(key, organization) do
- url = url("/api/auth?domain=docs&resource=#{organization}")
- fun = fn -> Hexdocs.HTTP.get(url, headers(key)) end
-
- case Hexdocs.HTTP.retry("hexpm", url, fun) do
- {:ok, status, _headers, _body} when status in 200..299 ->
- :ok
-
- {:ok, status, _headers, body} when status in [401, 403] ->
- body = JSON.decode!(body)
-
- if body["message"] in @refresh_errors do
- :refresh
- else
- {:error, body["message"]}
- end
- end
- end
-
def get_package(repo, package) do
key = Application.get_env(:hexdocs, :hexpm_secret)
url = url("/api/repos/#{repo}/packages/#{package}")
@@ -56,20 +31,10 @@ defmodule Hexdocs.Hexpm.Impl do
Application.get_env(:hexdocs, :hexpm_url) <> path
end
- defp headers(key_or_token) do
- # Support both legacy API keys and OAuth Bearer tokens
- # OAuth tokens are JWTs that start with "eyJ" (base64 of '{"')
- # Legacy API keys are shorter hex strings
- authorization =
- if String.starts_with?(key_or_token, "eyJ") do
- "Bearer #{key_or_token}"
- else
- key_or_token
- end
-
+ defp headers(key) do
[
{"accept", "application/json"},
- {"authorization", authorization}
+ {"authorization", key}
]
end
end
diff --git a/lib/hexdocs/http.ex b/lib/hexdocs/http.ex
index 4bcc31f..295672b 100644
--- a/lib/hexdocs/http.ex
+++ b/lib/hexdocs/http.ex
@@ -5,21 +5,6 @@ defmodule Hexdocs.HTTP do
require Logger
- def head(url, headers) do
- case Req.head(url,
- headers: headers,
- retry: false,
- decode_body: false,
- receive_timeout: @receive_timeout
- ) do
- {:ok, response} ->
- {:ok, response.status, normalize_headers(response.headers)}
-
- {:error, reason} ->
- {:error, reason}
- end
- end
-
def get(url, headers, opts \\ []) do
timeout = Keyword.get(opts, :receive_timeout, @receive_timeout)
@@ -37,23 +22,6 @@ defmodule Hexdocs.HTTP do
end
end
- def get_stream(url, headers) do
- case Req.get(url,
- headers: headers,
- retry: false,
- decode_body: false,
- receive_timeout: @receive_timeout,
- into: :self
- ) do
- {:ok, response} ->
- stream = stream_body(response.body.ref)
- {:ok, response.status, normalize_headers(response.headers), stream}
-
- {:error, reason} ->
- {:error, reason}
- end
- end
-
def put(url, headers, body) do
case Req.put(url,
headers: headers,
@@ -127,26 +95,6 @@ defmodule Hexdocs.HTTP do
Enum.map(headers, fn {name, values} -> {name, Enum.join(values, ", ")} end)
end
- defp stream_body(ref) do
- start_fun = fn -> :cont end
- after_fun = fn _ -> :ok end
-
- next_fun = fn
- :cont ->
- receive do
- {^ref, {:data, data}} -> {[{:ok, data}], :cont}
- {^ref, :done} -> {:halt, :ok}
- after
- 30_000 -> {[{:error, :timeout}], :stop}
- end
-
- :stop ->
- {:halt, :ok}
- end
-
- Stream.resource(start_fun, next_fun, after_fun)
- end
-
def retry(service, url, fun) do
retry(fun, service, url, 0)
end
diff --git a/lib/hexdocs/oauth.ex b/lib/hexdocs/oauth.ex
deleted file mode 100644
index 90af172..0000000
--- a/lib/hexdocs/oauth.ex
+++ /dev/null
@@ -1,191 +0,0 @@
-defmodule Hexdocs.OAuth do
- @moduledoc """
- OAuth 2.0 Authorization Code with PKCE client for hexdocs.
-
- This module implements the OAuth 2.0 Authorization Code flow with PKCE (Proof Key for
- Code Exchange) as defined in RFC 7636. It can be used by any application integrating
- with hexpm's OAuth infrastructure.
-
- ## Flow
-
- 1. Generate code_verifier and code_challenge using `generate_code_verifier/0` and
- `generate_code_challenge/1`
- 2. Build authorization URL with `authorization_url/1` and redirect user
- 3. After user authorizes, exchange the code for tokens with `exchange_code/3`
- 4. Use `refresh_token/2` to get new access tokens before expiration
- """
-
- @doc """
- Generate a cryptographically random code_verifier for PKCE.
-
- Returns a 43-character URL-safe base64 string (32 random bytes encoded).
- """
- def generate_code_verifier do
- :crypto.strong_rand_bytes(32)
- |> Base.url_encode64(padding: false)
- end
-
- @doc """
- Generate code_challenge from code_verifier using S256 method.
-
- Computes SHA-256 hash of the verifier and base64url encodes it.
- """
- def generate_code_challenge(verifier) do
- :crypto.hash(:sha256, verifier)
- |> Base.url_encode64(padding: false)
- end
-
- @doc """
- Generate a random state parameter for CSRF protection.
- """
- def generate_state do
- :crypto.strong_rand_bytes(16)
- |> Base.url_encode64(padding: false)
- end
-
- @doc """
- Build the OAuth authorization URL with PKCE parameters.
-
- ## Options (all required)
-
- * `:hexpm_url` - Base URL of hexpm (e.g., "https://hex.pm")
- * `:client_id` - OAuth client ID
- * `:redirect_uri` - URI to redirect to after authorization
- * `:scope` - Space-separated scopes to request
- * `:state` - Random state for CSRF protection
- * `:code_challenge` - PKCE code challenge
-
- """
- def authorization_url(opts) do
- hexpm_url = Keyword.fetch!(opts, :hexpm_url)
- client_id = Keyword.fetch!(opts, :client_id)
- redirect_uri = Keyword.fetch!(opts, :redirect_uri)
- scope = Keyword.fetch!(opts, :scope)
- state = Keyword.fetch!(opts, :state)
- code_challenge = Keyword.fetch!(opts, :code_challenge)
-
- query =
- URI.encode_query(%{
- "response_type" => "code",
- "client_id" => client_id,
- "redirect_uri" => redirect_uri,
- "scope" => scope,
- "state" => state,
- "code_challenge" => code_challenge,
- "code_challenge_method" => "S256"
- })
-
- "#{hexpm_url}/oauth/authorize?#{query}"
- end
-
- @doc """
- Exchange an authorization code for access and refresh tokens.
-
- ## Parameters
-
- * `code` - The authorization code received from the callback
- * `code_verifier` - The original code_verifier generated before authorization
- * `opts` - Keyword list with:
- * `:hexpm_url` - Base URL of hexpm
- * `:client_id` - OAuth client ID
- * `:client_secret` - OAuth client secret
- * `:redirect_uri` - The same redirect_uri used in authorization
-
- ## Returns
-
- * `{:ok, tokens}` - Map with "access_token", "refresh_token", "expires_in", etc.
- * `{:error, reason}` - Error tuple with status code and error response
- """
- def exchange_code(code, code_verifier, opts) do
- hexpm_url = Keyword.fetch!(opts, :hexpm_url)
- client_id = Keyword.fetch!(opts, :client_id)
- client_secret = Keyword.fetch!(opts, :client_secret)
- redirect_uri = Keyword.fetch!(opts, :redirect_uri)
-
- body =
- %{
- "grant_type" => "authorization_code",
- "code" => code,
- "redirect_uri" => redirect_uri,
- "client_id" => client_id,
- "client_secret" => client_secret,
- "code_verifier" => code_verifier
- }
- |> maybe_put("name", opts[:name])
- |> JSON.encode!()
-
- url = "#{hexpm_url}/api/oauth/token"
- headers = [{"content-type", "application/json"}]
-
- case Hexdocs.HTTP.post(url, headers, body) do
- {:ok, status, _headers, response_body} when status in 200..299 ->
- {:ok, JSON.decode!(response_body)}
-
- {:ok, status, _headers, response_body} ->
- {:error, {status, JSON.decode!(response_body)}}
-
- {:error, reason} ->
- {:error, reason}
- end
- end
-
- @doc """
- Refresh an access token using a refresh token.
-
- ## Parameters
-
- * `refresh_token` - The refresh token from a previous token response
- * `opts` - Keyword list with:
- * `:hexpm_url` - Base URL of hexpm
- * `:client_id` - OAuth client ID
- * `:client_secret` - OAuth client secret
-
- ## Returns
-
- * `{:ok, tokens}` - Map with new "access_token", "refresh_token", "expires_in", etc.
- * `{:error, reason}` - Error tuple
- """
- def refresh_token(refresh_token, opts) do
- hexpm_url = Keyword.fetch!(opts, :hexpm_url)
- client_id = Keyword.fetch!(opts, :client_id)
- client_secret = Keyword.fetch!(opts, :client_secret)
-
- body =
- JSON.encode!(%{
- "grant_type" => "refresh_token",
- "refresh_token" => refresh_token,
- "client_id" => client_id,
- "client_secret" => client_secret
- })
-
- url = "#{hexpm_url}/api/oauth/token"
- headers = [{"content-type", "application/json"}]
-
- case Hexdocs.HTTP.post(url, headers, body) do
- {:ok, status, _headers, response_body} when status in 200..299 ->
- {:ok, JSON.decode!(response_body)}
-
- {:ok, status, _headers, response_body} ->
- {:error, {status, JSON.decode!(response_body)}}
-
- {:error, reason} ->
- {:error, reason}
- end
- end
-
- @doc """
- Get the OAuth configuration from application environment.
-
- Returns a keyword list with all OAuth settings needed for API calls.
- """
- def config do
- [
- hexpm_url: Application.get_env(:hexdocs, :hexpm_url),
- client_id: Application.get_env(:hexdocs, :oauth_client_id),
- client_secret: Application.get_env(:hexdocs, :oauth_client_secret)
- ]
- end
-
- defp maybe_put(map, _key, nil), do: map
- defp maybe_put(map, key, value), do: Map.put(map, key, value)
-end
diff --git a/lib/hexdocs/plug.ex b/lib/hexdocs/plug.ex
deleted file mode 100644
index 3f182b5..0000000
--- a/lib/hexdocs/plug.ex
+++ /dev/null
@@ -1,423 +0,0 @@
-defmodule Hexdocs.Plug do
- use Plug.Builder
- use Plug.ErrorHandler
- require Logger
-
- # OAuth token refresh buffer - refresh token 5 minutes before expiry
- @token_refresh_buffer 5 * 60
-
- use Sentry.PlugCapture
-
- if Mix.env() == :dev do
- use Plug.Debugger, otp_app: :my_app
- end
-
- plug(Hexdocs.Plug.Status)
- plug(Hexdocs.Plug.Forwarded)
- plug(Plug.RequestId)
-
- plug(Plug.Static,
- at: "/",
- from: :hexdocs,
- gzip: true,
- only: ~w(css fonts images js),
- only_matching: ~w(favicon robots)
- )
-
- if Mix.env() != :test do
- plug(Logster.Plugs.Logger, excludes: [:params])
- end
-
- plug(Sentry.PlugContext)
-
- plug(Plug.Head)
-
- if Mix.env() == :prod do
- plug(Plug.SSL, rewrite_on: [:x_forwarded_proto])
- end
-
- plug(:security_headers)
-
- # TODO: Use MFAs
- plug(Plug.Session,
- store: :cookie,
- key: "_hexdocs_key",
- signing_salt: {Application, :get_env, [:hexdocs, :session_signing_salt]},
- encryption_salt: {Application, :get_env, [:hexdocs, :session_encryption_salt]},
- max_age: 60 * 60 * 24 * 30,
- secure: Mix.env() == :prod,
- http_only: true,
- same_site: "Lax"
- )
-
- plug(:put_secret_key_base)
- plug(:fetch_session)
- plug(:fetch_query_params)
- plug(:run)
-
- defp security_headers(conn, _opts) do
- conn
- |> put_resp_header("x-content-type-options", "nosniff")
- |> put_resp_header("x-frame-options", "SAMEORIGIN")
- |> put_resp_header("referrer-policy", "strict-origin-when-cross-origin")
- end
-
- defp put_secret_key_base(conn, _opts) do
- put_in(conn.secret_key_base, Application.get_env(:hexdocs, :session_key_base))
- end
-
- defp run(conn, _opts) do
- if conn.host == Application.get_env(:hexdocs, :private_host) do
- redirect_to_hexpm(conn)
- else
- case subdomain(conn.host) do
- :error ->
- send_resp(conn, 400, "")
-
- {:ok, 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
- send_redirect(conn, 301, Application.get_env(:hexdocs, :hexpm_url))
- end
-
- 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
- code_verifier = Hexdocs.OAuth.generate_code_verifier()
- code_challenge = Hexdocs.OAuth.generate_code_challenge(code_verifier)
- state = Hexdocs.OAuth.generate_state()
-
- redirect_uri = build_oauth_redirect_uri(conn, organization)
-
- url =
- Hexdocs.OAuth.authorization_url(
- hexpm_url: Application.get_env(:hexdocs, :hexpm_url),
- client_id: Application.get_env(:hexdocs, :oauth_client_id),
- redirect_uri: redirect_uri,
- scope: "docs:#{organization}",
- state: state,
- code_challenge: code_challenge
- )
-
- conn
- |> put_session("oauth_code_verifier", code_verifier)
- |> put_session("oauth_state", state)
- |> put_session("oauth_return_path", safe_return_path(conn.request_path))
- |> redirect(url)
- end
-
- defp build_oauth_redirect_uri(_conn, organization) do
- scheme = Application.get_env(:hexdocs, :scheme)
- host = Application.get_env(:hexdocs, :private_host)
- "#{scheme}://#{Hexdocs.Utils.name_to_subdomain(organization)}.#{host}/oauth/callback"
- end
-
- defp handle_oauth_callback(conn, organization) do
- code = conn.query_params["code"]
- state = conn.query_params["state"]
- error = conn.query_params["error"]
- stored_state = get_session(conn, "oauth_state")
- code_verifier = get_session(conn, "oauth_code_verifier")
- return_path = get_session(conn, "oauth_return_path") || "/"
-
- cond do
- error ->
- # User denied authorization or other OAuth error
- error_description = conn.query_params["error_description"] || error
- send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error_description))
-
- is_nil(state) or state != stored_state ->
- send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: "Invalid OAuth state"))
-
- is_nil(code) ->
- send_resp(conn, 400, Hexdocs.Templates.auth_error(reason: "Missing authorization code"))
-
- true ->
- exchange_oauth_code(conn, code, code_verifier, organization, return_path)
- end
- end
-
- defp exchange_oauth_code(conn, code, code_verifier, organization, return_path) do
- redirect_uri = build_oauth_redirect_uri(conn, organization)
-
- opts =
- Hexdocs.OAuth.config()
- |> Keyword.put(:redirect_uri, redirect_uri)
- |> Keyword.put(:name, organization)
-
- case Hexdocs.OAuth.exchange_code(code, code_verifier, opts) do
- {:ok, tokens} ->
- conn
- |> delete_session("oauth_code_verifier")
- |> delete_session("oauth_state")
- |> delete_session("oauth_return_path")
- |> store_oauth_tokens(tokens)
- |> redirect(return_path)
-
- {:error, {_status, %{"error_description" => description}}} ->
- send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: description))
-
- {:error, {_status, %{"error" => error}}} ->
- send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: error))
-
- {:error, reason} ->
- Logger.error("OAuth code exchange failed: #{inspect(reason)}")
- send_resp(conn, 500, Hexdocs.Templates.auth_error(reason: "Authentication failed"))
- end
- end
-
- defp store_oauth_tokens(conn, tokens) do
- now = NaiveDateTime.utc_now()
- expires_in = tokens["expires_in"] || 1800
- expires_at = NaiveDateTime.add(now, expires_in, :second)
-
- conn
- |> put_session("access_token", tokens["access_token"])
- |> put_session("refresh_token", tokens["refresh_token"])
- |> put_session("token_expires_at", expires_at)
- |> put_session("token_created_at", now)
- end
-
- defp try_serve_page_oauth(conn, organization, access_token) do
- expires_at = get_session(conn, "token_expires_at")
- refresh_token = get_session(conn, "refresh_token")
-
- cond do
- # Token needs refresh
- token_needs_refresh?(expires_at) and refresh_token ->
- case refresh_oauth_token(conn, refresh_token, organization) do
- {:ok, conn, new_access_token} ->
- serve_if_valid_oauth(conn, organization, new_access_token)
-
- {:error, _reason} ->
- # Refresh failed, re-authenticate
- redirect_oauth(conn, organization)
- end
-
- # Token expired and no refresh token
- token_expired?(expires_at) ->
- redirect_oauth(conn, organization)
-
- # Token is valid, serve the page
- true ->
- serve_if_valid_oauth(conn, organization, access_token)
- end
- end
-
- defp token_needs_refresh?(nil), do: true
-
- defp token_needs_refresh?(expires_at) do
- now = NaiveDateTime.utc_now()
- diff = NaiveDateTime.diff(expires_at, now)
- diff <= @token_refresh_buffer
- end
-
- defp token_expired?(nil), do: true
-
- defp token_expired?(expires_at) do
- NaiveDateTime.compare(NaiveDateTime.utc_now(), expires_at) == :gt
- end
-
- defp refresh_oauth_token(conn, refresh_token, _organization) do
- opts = Hexdocs.OAuth.config()
-
- case Hexdocs.OAuth.refresh_token(refresh_token, opts) do
- {:ok, tokens} ->
- conn = store_oauth_tokens(conn, tokens)
- {:ok, conn, tokens["access_token"]}
-
- {:error, reason} ->
- Logger.warning("OAuth token refresh failed: #{inspect(reason)}")
- {:error, reason}
- end
- end
-
- defp serve_if_valid_oauth(conn, organization, access_token) do
- case Hexdocs.Hexpm.verify_key(access_token, organization) do
- :ok ->
- serve_page(conn, organization)
-
- :refresh ->
- # Token was rejected, try to refresh or re-authenticate
- refresh_token = get_session(conn, "refresh_token")
-
- if refresh_token do
- case refresh_oauth_token(conn, refresh_token, organization) do
- {:ok, conn, new_access_token} ->
- # Retry verification with new token
- case Hexdocs.Hexpm.verify_key(new_access_token, organization) do
- :ok -> serve_page(conn, organization)
- _ -> redirect_oauth(conn, organization)
- end
-
- {:error, _} ->
- redirect_oauth(conn, organization)
- end
- else
- redirect_oauth(conn, organization)
- end
-
- {:error, message} ->
- send_resp(conn, 403, Hexdocs.Templates.auth_error(reason: message))
- end
- end
-
- defp subdomain(host) do
- private_host = Application.get_env(:hexdocs, :private_host)
-
- case String.split(host, ".", parts: 2) do
- [subdomain, ^private_host] -> {:ok, subdomain}
- _ -> :error
- end
- end
-
- defp serve_page(conn, organization) do
- uri = rewrite_uri(conn)
- bucket_path = organization <> uri.path
-
- case fetch_page(bucket_path, uri.path) do
- {:ok, {200, headers, stream}} ->
- conn
- |> transfer_headers(headers)
- |> send_chunked(200)
- |> stream_body(stream)
-
- {:ok, {404, headers, _consumed_stream}} ->
- conn
- |> transfer_headers(headers)
- |> put_resp_content_type("text/html")
- |> send_resp(404, Hexdocs.Templates.not_found([]))
-
- {:redirect, path} ->
- redirect(conn, path)
- end
- end
-
- defp rewrite_uri(conn) do
- uri = URI.parse(conn.request_path)
- %URI{path: rewrite_path(uri.path)}
- end
-
- defp rewrite_path(nil) do
- nil
- end
-
- defp rewrite_path(path) do
- String.replace(path, ~r"^/([^/]*)/[^/]*/docs_config.js$", "/\\1/docs_config.js")
- end
-
- defp fetch_page(bucket_path, path) do
- if String.ends_with?(bucket_path, "/") do
- {:ok, stream_page(Path.join(bucket_path, "index.html"))}
- else
- case stream_page(bucket_path) do
- {404, headers, stream} ->
- # Read full body, since we don't use HTTP continuations
- Stream.run(stream)
-
- case head_page(Path.join(bucket_path, "index.html")) do
- {200, _headers} -> {:redirect, path <> "/"}
- _other -> {:ok, {404, headers, :stream_consumed}}
- end
-
- other ->
- {:ok, other}
- end
- end
- end
-
- defp stream_page(path) do
- Hexdocs.Store.stream_page(:docs_private_bucket, path)
- end
-
- defp head_page(path) do
- Hexdocs.Store.head_page(:docs_private_bucket, path)
- end
-
- defp stream_body(conn, stream) do
- Enum.reduce_while(stream, conn, fn
- {:ok, chunk}, conn ->
- case chunk(conn, chunk) do
- {:ok, conn} ->
- {:cont, conn}
-
- {:error, reason} ->
- Logger.warning("Streaming sink error: #{inspect(reason)}")
- {:halt, conn}
- end
-
- {:error, reason}, conn ->
- # We stop streaming before sending the full body but cowboy
- # will clean up the connection for us
- Logger.warning("Streaming source error: #{inspect(reason)}")
- {:halt, conn}
- end)
- end
-
- @transfer_headers ~w(content-length content-type cache-control expires last-modified etag)
-
- defp transfer_headers(conn, headers) do
- headers = Map.new(headers, fn {key, value} -> {String.downcase(key), value} end)
-
- Enum.reduce(@transfer_headers, conn, fn header_name, conn ->
- case Map.fetch(headers, header_name) do
- {:ok, value} -> put_resp_header(conn, header_name, value)
- :error -> conn
- end
- end)
- end
-
- defp safe_return_path("/" <> rest = path) do
- if String.starts_with?(rest, "/") do
- "/"
- else
- path
- end
- end
-
- 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(status, body)
- end
-end
diff --git a/lib/hexdocs/plug/forwarded.ex b/lib/hexdocs/plug/forwarded.ex
deleted file mode 100644
index 2170be1..0000000
--- a/lib/hexdocs/plug/forwarded.ex
+++ /dev/null
@@ -1,32 +0,0 @@
-defmodule Hexdocs.Plug.Forwarded do
- import Plug.Conn
- require Logger
-
- def init(opts), do: opts
-
- def call(conn, _opts) do
- ip = get_req_header(conn, "x-forwarded-for")
- %{conn | remote_ip: ip(ip) || conn.remote_ip}
- end
-
- defp ip([ip | _]) do
- ip = :binary.split(ip, ",", [:global]) |> List.last()
-
- if ip do
- ip = String.trim(ip)
- parts = :binary.split(ip, ".", [:global])
- parts = Enum.map(parts, &Integer.parse/1)
- valid = Enum.all?(parts, &match?({int, ""} when int in 0..255, &1))
-
- if length(parts) == 4 and valid do
- parts = Enum.map(parts, &elem(&1, 0))
- List.to_tuple(parts)
- else
- Logger.warning("Invalid IP: #{inspect(ip)}")
- nil
- end
- end
- end
-
- defp ip(_), do: nil
-end
diff --git a/lib/hexdocs/plug/status.ex b/lib/hexdocs/plug/status.ex
deleted file mode 100644
index 221ff98..0000000
--- a/lib/hexdocs/plug/status.ex
+++ /dev/null
@@ -1,16 +0,0 @@
-defmodule Hexdocs.Plug.Status do
- import Plug.Conn
- alias Plug.Conn
-
- def init(opts), do: opts
-
- def call(%Conn{path_info: ["status"]} = conn, _opts) do
- conn
- |> send_resp(200, "")
- |> halt()
- end
-
- def call(conn, _opts) do
- conn
- end
-end
diff --git a/lib/hexdocs/store/gs.ex b/lib/hexdocs/store/gs.ex
index d041310..1cd482d 100644
--- a/lib/hexdocs/store/gs.ex
+++ b/lib/hexdocs/store/gs.ex
@@ -9,33 +9,6 @@ defmodule Hexdocs.Store.GS do
list_stream(bucket, prefix)
end
- def head_page(bucket, key, _opts) do
- url = url(bucket, key)
-
- {:ok, status, headers} =
- Hexdocs.HTTP.retry("gs", url, fn -> Hexdocs.HTTP.head(url, headers()) end)
-
- {status, headers}
- end
-
- def get_page(bucket, key, _opts) do
- url = url(bucket, key)
-
- {:ok, status, headers, body} =
- Hexdocs.HTTP.retry("gs", url, fn -> Hexdocs.HTTP.get(url, headers()) end)
-
- {status, headers, body}
- end
-
- def stream_page(bucket, key, _opts) do
- url = url(bucket, key)
-
- {:ok, status, headers, stream} =
- Hexdocs.HTTP.retry("gs", url, fn -> Hexdocs.HTTP.get_stream(url, headers()) end)
-
- {status, headers, stream}
- end
-
def put(bucket, key, blob, opts) do
headers =
headers() ++
diff --git a/lib/hexdocs/store/impl.ex b/lib/hexdocs/store/impl.ex
index c90e67b..fce5d81 100644
--- a/lib/hexdocs/store/impl.ex
+++ b/lib/hexdocs/store/impl.ex
@@ -17,21 +17,6 @@ defmodule Hexdocs.Store.Impl do
impl.get_to_file(name, key, dest, opts)
end
- def head_page(bucket, key, opts) do
- {impl, name} = bucket(bucket)
- impl.head_page(name, key, opts)
- end
-
- def get_page(bucket, key, opts) do
- {impl, name} = bucket(bucket)
- impl.get_page(name, key, opts)
- end
-
- def stream_page(bucket, key, opts) do
- {impl, name} = bucket(bucket)
- impl.stream_page(name, key, opts)
- end
-
def put(bucket, key, body, opts) do
{impl, name} = bucket(bucket)
impl.put(name, key, body, opts)
diff --git a/lib/hexdocs/store/local.ex b/lib/hexdocs/store/local.ex
index b93b615..ca4d76e 100644
--- a/lib/hexdocs/store/local.ex
+++ b/lib/hexdocs/store/local.ex
@@ -37,36 +37,6 @@ defmodule Hexdocs.Store.Local do
end
end
- def head_page(bucket, key, _opts) do
- path = Path.join([dir(), bucket(bucket), key])
-
- if File.regular?(path) do
- {200, []}
- else
- {404, []}
- end
- end
-
- def get_page(bucket, key, _opts) do
- path = Path.join([dir(), bucket(bucket), key])
-
- case File.read(path) do
- {:ok, contents} -> {200, [], contents}
- {:error, :eisdir} -> {404, [], ""}
- {:error, :enoent} -> {404, [], ""}
- end
- end
-
- def stream_page(bucket, key, _opts) do
- path = Path.join([dir(), bucket(bucket), key])
-
- case File.read(path) do
- {:ok, contents} -> {200, [], Stream.map(:binary.bin_to_list(contents), &{:ok, <<&1>>})}
- {:error, :eisdir} -> {404, [], []}
- {:error, :enoent} -> {404, [], []}
- end
- end
-
def put(bucket, key, blob, _opts) do
path = Path.join([dir(), bucket(bucket), key])
File.mkdir_p!(Path.dirname(path))
diff --git a/lib/hexdocs/store/store.ex b/lib/hexdocs/store/store.ex
index 67e753b..4dafcee 100644
--- a/lib/hexdocs/store/store.ex
+++ b/lib/hexdocs/store/store.ex
@@ -4,8 +4,6 @@ defmodule Hexdocs.Store do
@type key :: String.t()
@type body :: binary
@type opts :: Keyword.t()
- @type status :: 100..599
- @type headers :: %{String.t() => String.t()}
defmodule Repo do
@type bucket :: atom
@@ -23,15 +21,9 @@ defmodule Hexdocs.Store do
@type prefix :: key
@type key :: String.t()
@type body :: binary
- @type stream :: Enum.t()
@type opts :: Keyword.t()
- @type status :: 100..599
- @type headers :: %{String.t() => String.t()}
@callback list(bucket, prefix) :: [key]
- @callback head_page(bucket, key, opts) :: {status, headers}
- @callback get_page(bucket, key, opts) :: {status, headers, body}
- @callback stream_page(bucket, key, opts) :: {status, headers, stream}
@callback put(bucket, key, body, opts) :: term
@callback put!(bucket, key, body, opts) :: term
@callback put_file!(bucket, key, source :: String.t(), opts) :: term
@@ -43,9 +35,6 @@ defmodule Hexdocs.Store do
def list(bucket, prefix), do: impl().list(bucket, prefix)
def get(bucket, key, opts \\ []), do: impl().get(bucket, key, opts)
def get_to_file(bucket, key, dest, opts \\ []), do: impl().get_to_file(bucket, key, dest, opts)
- def head_page(bucket, key, opts \\ []), do: impl().head_page(bucket, key, opts)
- def get_page(bucket, key, opts \\ []), do: impl().get_page(bucket, key, opts)
- def stream_page(bucket, key, opts \\ []), do: impl().stream_page(bucket, key, opts)
def put(bucket, key, body, opts \\ []), do: impl().put(bucket, key, body, opts)
def put!(bucket, key, body, opts \\ []), do: impl().put!(bucket, key, body, opts)
def put_file!(bucket, key, source, opts \\ []), do: impl().put_file!(bucket, key, source, opts)
diff --git a/lib/hexdocs/templates.ex b/lib/hexdocs/templates.ex
deleted file mode 100644
index f5e453c..0000000
--- a/lib/hexdocs/templates.ex
+++ /dev/null
@@ -1,10 +0,0 @@
-defmodule Hexdocs.Templates do
- require EEx
-
- templates = Path.wildcard(Path.join(__DIR__, "templates/*"))
-
- Enum.each(templates, fn template ->
- name = Path.basename(template, ".html.eex") |> String.to_atom()
- EEx.function_from_file(:def, name, template, [:assigns])
- end)
-end
diff --git a/lib/hexdocs/templates/auth_error.html.eex b/lib/hexdocs/templates/auth_error.html.eex
deleted file mode 100644
index 7137cdf..0000000
--- a/lib/hexdocs/templates/auth_error.html.eex
+++ /dev/null
@@ -1,22 +0,0 @@
-
-
-
-
-
- Authentication error - HexDocs
-
-
-
-
-
-
-
-
-
Authentication error
-
- You are not able to access this page: <%= @reason %>
-
-
-
-
-
diff --git a/lib/hexdocs/templates/not_found.html.eex b/lib/hexdocs/templates/not_found.html.eex
deleted file mode 100644
index 3d0cd41..0000000
--- a/lib/hexdocs/templates/not_found.html.eex
+++ /dev/null
@@ -1,39 +0,0 @@
-<% _ = assigns %>
-
-
-
-
-
-
- Page not found - HexDocs
-
-
-
-
-
-
-
-
-
Page not found
-
- Is something wrong with the package documentation? Open an issue on the project the documentation belongs to. Most packages link to their repository from their hex.pm page .
-
-
-
- If you are seeing this as the publisher of a package you may want to verify that you configured ex_doc correctly for your project or if you believe the issue is with ex_doc open an issue there.
-
-
-
- Go back to Hex
-
-
-
-
- Is something wrong? Let us know by opening an issue or emailing support .
-
-
-
-
-
-
-
diff --git a/mix.exs b/mix.exs
index 48af473..fc3c0bf 100644
--- a/mix.exs
+++ b/mix.exs
@@ -43,8 +43,6 @@ defmodule Hexdocs.MixProject do
{:ex_aws_sqs, "~> 3.0"},
{:goth, "~> 1.0"},
{:req, "~> 0.6.1"},
- {:logster, "~> 1.0"},
- {:bandit, "~> 1.0"},
{:sentry, "~> 13.0"},
{:ssl_verify_fun, "~> 1.1", manager: :rebar3, override: true},
{:sweet_xml, "~> 0.7.0"},
diff --git a/mix.lock b/mix.lock
index 17cfa76..a015343 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,5 +1,4 @@
%{
- "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"},
"broadway": {:hex, :broadway, "1.3.0", "f75f6376159b74f55c5ba2629dac613e4fd79d9e71148ab5fbac8fdd7c999d2a", [:mix], [{:gen_stage, "~> 1.0", [hex: :gen_stage, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bef3b4c5512d0072917b70239cbecf8f76a2587465a5b7c3e2b9ae18b4bc405b"},
"broadway_sqs": {:hex, :broadway_sqs, "0.7.4", "ab89b298f9253adb8534f92095b56d4879e35fe2f5a0730256f7e824572c637f", [:mix], [{:broadway, "~> 1.0", [hex: :broadway, repo: "hexpm", optional: false]}, {:ex_aws_sqs, "~> 3.2.1 or ~> 3.3", [hex: :ex_aws_sqs, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.3.7 or ~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:saxy, "~> 1.1", [hex: :saxy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7140085c4f7c4b27886b3a8f3d0942976f39f195fdbc2f652c5d7b157f93ae28"},
"ex_aws": {:hex, :ex_aws, "2.7.0", "e6bfd4b5fb8c791aa6c7d57fc7c45f050bb89de9576f1f6104aa974b234c01ec", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bfe9d744d4fd4c1f40314ee7fab504d5547d1f01cd377fff1568cbe630b06d65"},
@@ -12,21 +11,16 @@
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
- "logster": {:hex, :logster, "1.1.1", "d6fddac540dd46adde0c894024500867fe63b0043713f842c62da5815e21db10", [:mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "d18e852c430812ad1c9756998ebe46ec814c724e6eb551a512d7e3f8dee24cef"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
"mox": {:hex, :mox, "1.2.0", "a2cd96b4b80a3883e3100a221e8adc1b98e4c3a332a8fc434c39526babafd5b3", [:mix], [{:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}], "hexpm", "c7b92b3cc69ee24a7eeeaf944cd7be22013c52fcb580c1f33f50845ec821089a"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
- "plug": {:hex, :plug, "1.20.2", "adbee2441232412e37fbb357fd5e4cd533fdd253b29f2e1992262b0f1fb01462", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b16baf55877d60891002ffc1ce0b3ff7d6f30a38a23e02e4d4293c4ac266f136"},
- "plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.6.2", "b9b2024f35bcf60a92cc8cad2eaaf9d4e7aace463ff74be1afe5986830184413", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cc9cd30a2ddd04989929b887178e1610c940456d962c6c3a52df6146d2eef9bf"},
"saxy": {:hex, :saxy, "1.6.0", "02cb4e9bd045f25ac0c70fae8164754878327ee393c338a090288210b02317ee", [:mix], [], "hexpm", "ef42eb4ac983ca77d650fbdb68368b26570f6cc5895f0faa04d34a6f384abad3"},
"sentry": {:hex, :sentry, "13.2.0", "edef8afdbe3bbdae141c2a1a18661c214d9af57308ad6bd41b2182c6e9506382", [:mix], [{:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, ">= 1.8.0 and < 5.0.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:opentelemetry, ">= 0.0.0", [hex: :opentelemetry, repo: "hexpm", optional: true]}, {:opentelemetry_api, ">= 0.0.0", [hex: :opentelemetry_api, repo: "hexpm", optional: true]}, {:opentelemetry_exporter, ">= 0.0.0", [hex: :opentelemetry_exporter, repo: "hexpm", optional: true]}, {:opentelemetry_semantic_conventions, ">= 0.0.0", [hex: :opentelemetry_semantic_conventions, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_live_view, "~> 0.20 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.6", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "f3397760ba0f0a2d8abb969c3e9f95e909839f7c45957249ee229c0e9738a3b4"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
"telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"},
- "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
- "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
}
diff --git a/priv/static/css/app.css b/priv/static/css/app.css
deleted file mode 100644
index 4d14805..0000000
--- a/priv/static/css/app.css
+++ /dev/null
@@ -1,215 +0,0 @@
-html {
- font-size: 100%;
- -ms-text-size-adjust: 100%;
- -webkit-text-size-adjust: 100%;
-}
-
-html {
- font-family: sans-serif;
-}
-
-body {
- margin: 0;
-}
-
-a:focus {
- outline: thin dotted;
-}
-
-a:active,
-a:hover {
- outline: 0;
-}
-
-h1 {
- font-size: 2em;
- margin: .67em 0;
-}
-
-p {
- margin: 1em 0;
-}
-
-button::-moz-focus-inner,
-input::-moz-focus-inner {
- border: 0;
- padding: 0;
-}
-
-.opera-only:-o-prefocus {
- word-spacing: -.43em;
-}
-
-.pure-button::-moz-focus-inner {
- padding: 0;
- border: 0;
-}
-
-.pure-button::-moz-focus-inner {
- padding: 0;
- border: 0;
-}
-
-.opera-only:-o-prefocus {
- word-spacing: -.43em;
-}
-
-* {
- -webkit-box-sizing: border-box;
- -moz-box-sizing: border-box;
- box-sizing: border-box;
-}
-
-body {
- line-height: 1.7em;
- color: #7f8c8d;
- font-size: 13px;
-}
-
-h1 {
- color: #34495e;
-}
-
-a:link {
- color: #000;
- text-decoration: underline;
-}
-
-a:active {
- color: #000;
- text-decoration: underline;
-}
-
-a:visited {
- color: #000;
- text-decoration: underline;
-}
-
-a:hover {
- color: #000;
- text-decoration: none;
-}
-
-.splash-container {
- background: #fff;
- z-index: 1;
- overflow: hidden;
- width: 100%;
- height: 100%;
- top: 0;
- left: 0;
-}
-
-.splash {
- width: 80%;
- height: 50%;
- margin: auto;
- position: absolute;
- top: 100px;
- left: 0;
- right: 0;
- text-align: center;
-}
-
-.splash-subhead {
- color: black;
- letter-spacing: 0.05em;
- opacity: 0.8;
- text-transform: uppercase;
-}
-
-@media (min-width:48em) {
- body {
- font-size: 16px;
- }
-
- .splash {
- width: 50%;
- height: 50%;
- }
-}
-
-h1,
-h3 {
- color: #333;
- text-transform: uppercase;
-}
-
-small {
- text-transform: uppercase;
-}
-
-.no-upcase {
- text-transform: none;
-}
-
-.sr-only {
- position: absolute;
- width: 1px;
- height: 1px;
- padding: 0;
- margin: -1px;
- overflow: hidden;
- clip: rect(0,0,0,0);
- border: 0;
-}
-
-#search {
- -webkit-appearance: none;
- padding: 2px 8px;
- width: 300px;
- border-top-left-radius: 4px;
- border-bottom-left-radius: 4px;
- border: 1px solid grey;
- border-right: none;
- line-height: 24px;
- outline: none;
-}
-
-#search-submit {
- padding: 2px 8px;
- border-top-right-radius: 4px;
- border-bottom-right-radius: 4px;
- line-height: 24px;
- color: grey;
- border-left: none;
- background-color: transparent;
- border: 1px solid grey;
- border-left: none;
- margin: 0;
-}
-
-#search-submit svg {
- position: relative;
- top: 1px;
- width: 16px;
- height: 16px;
- fill: currentColor;
- vertical-align: sub;
-}
-
-#search-results {
- list-style: none;
- padding: 8px;
- line-height: 28px;
- width: 320px;
- margin: 0 auto;
- background-color: #efefef;
- border: 1px solid #ddd;
- border-top: 0;
- border-bottom-left-radius: 4px;
- border-bottom-right-radius: 4px;
- color: #34495e;
-}
-
-#search-results:empty {
- display: none;
-}
-
-#search-results li {
- text-align: left;
-}
-
-#search-results a {
- text-decoration: none;
-}
diff --git a/priv/static/favicon.ico b/priv/static/favicon.ico
deleted file mode 100644
index f6ef040..0000000
Binary files a/priv/static/favicon.ico and /dev/null differ
diff --git a/priv/static/favicon.svg b/priv/static/favicon.svg
deleted file mode 100644
index 36db2b1..0000000
--- a/priv/static/favicon.svg
+++ /dev/null
@@ -1,17 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/test/hexdocs/http_test.exs b/test/hexdocs/http_test.exs
deleted file mode 100644
index 4fb0997..0000000
--- a/test/hexdocs/http_test.exs
+++ /dev/null
@@ -1,39 +0,0 @@
-defmodule Hexdocs.HTTPTest do
- use ExUnit.Case, async: true
-
- defmodule StreamingPlug do
- import Plug.Conn
-
- def init(opts), do: opts
-
- def call(conn, _opts) do
- conn = send_chunked(conn, 200)
- {:ok, conn} = chunk(conn, "chunk1")
- {:ok, conn} = chunk(conn, "chunk2")
- {:ok, conn} = chunk(conn, "chunk3")
- conn
- end
- end
-
- setup do
- pid = start_supervised!({Bandit, plug: StreamingPlug, scheme: :http, port: 0})
- {:ok, {_, port}} = ThousandIsland.listener_info(pid)
- {:ok, port: port}
- end
-
- describe "get_stream/2" do
- test "streams response body in chunks", %{port: port} do
- {:ok, status, _headers, stream} =
- Hexdocs.HTTP.get_stream("http://localhost:#{port}/", [])
-
- assert status == 200
-
- chunks =
- stream
- |> Enum.map(fn {:ok, chunk} -> chunk end)
- |> Enum.join()
-
- assert chunks == "chunk1chunk2chunk3"
- end
- end
-end
diff --git a/test/hexdocs/oauth_test.exs b/test/hexdocs/oauth_test.exs
deleted file mode 100644
index f511be1..0000000
--- a/test/hexdocs/oauth_test.exs
+++ /dev/null
@@ -1,154 +0,0 @@
-defmodule Hexdocs.OAuthTest do
- use ExUnit.Case, async: true
-
- alias Hexdocs.OAuth
-
- describe "generate_code_verifier/0" do
- test "generates a non-empty string" do
- verifier = OAuth.generate_code_verifier()
-
- assert is_binary(verifier)
- assert String.length(verifier) > 0
- end
-
- test "generates unique values" do
- verifier1 = OAuth.generate_code_verifier()
- verifier2 = OAuth.generate_code_verifier()
-
- assert verifier1 != verifier2
- end
-
- test "generates URL-safe base64 encoded string" do
- verifier = OAuth.generate_code_verifier()
-
- # Should not contain URL-unsafe characters
- refute String.contains?(verifier, "+")
- refute String.contains?(verifier, "/")
- refute String.contains?(verifier, "=")
- end
-
- test "generates 43-character string (32 bytes base64url encoded)" do
- verifier = OAuth.generate_code_verifier()
-
- # 32 bytes base64url encoded without padding = 43 characters
- assert String.length(verifier) == 43
- end
- end
-
- describe "generate_code_challenge/1" do
- test "generates a non-empty string" do
- verifier = OAuth.generate_code_verifier()
- challenge = OAuth.generate_code_challenge(verifier)
-
- assert is_binary(challenge)
- assert String.length(challenge) > 0
- end
-
- test "generates URL-safe base64 encoded string" do
- verifier = OAuth.generate_code_verifier()
- challenge = OAuth.generate_code_challenge(verifier)
-
- refute String.contains?(challenge, "+")
- refute String.contains?(challenge, "/")
- refute String.contains?(challenge, "=")
- end
-
- test "produces consistent output for same input" do
- verifier = OAuth.generate_code_verifier()
- challenge1 = OAuth.generate_code_challenge(verifier)
- challenge2 = OAuth.generate_code_challenge(verifier)
-
- assert challenge1 == challenge2
- end
-
- test "produces different output for different inputs" do
- verifier1 = OAuth.generate_code_verifier()
- verifier2 = OAuth.generate_code_verifier()
-
- challenge1 = OAuth.generate_code_challenge(verifier1)
- challenge2 = OAuth.generate_code_challenge(verifier2)
-
- assert challenge1 != challenge2
- end
-
- test "produces correct SHA-256 hash" do
- # Known test vector
- verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
-
- expected_challenge =
- :crypto.hash(:sha256, verifier)
- |> Base.url_encode64(padding: false)
-
- assert OAuth.generate_code_challenge(verifier) == expected_challenge
- end
- end
-
- describe "generate_state/0" do
- test "generates a non-empty string" do
- state = OAuth.generate_state()
-
- assert is_binary(state)
- assert String.length(state) > 0
- end
-
- test "generates unique values" do
- state1 = OAuth.generate_state()
- state2 = OAuth.generate_state()
-
- assert state1 != state2
- end
- end
-
- describe "authorization_url/1" do
- test "builds correct authorization URL" do
- url =
- OAuth.authorization_url(
- hexpm_url: "https://hex.pm",
- client_id: "hexdocs",
- redirect_uri: "https://acme.hexdocs.pm/oauth/callback",
- scope: "docs:acme",
- state: "random_state",
- code_challenge: "challenge123"
- )
-
- assert String.starts_with?(url, "https://hex.pm/oauth/authorize?")
-
- uri = URI.parse(url)
- query = URI.decode_query(uri.query)
-
- assert query["response_type"] == "code"
- assert query["client_id"] == "hexdocs"
- assert query["redirect_uri"] == "https://acme.hexdocs.pm/oauth/callback"
- assert query["scope"] == "docs:acme"
- assert query["state"] == "random_state"
- assert query["code_challenge"] == "challenge123"
- assert query["code_challenge_method"] == "S256"
- end
-
- test "properly encodes special characters in parameters" do
- url =
- OAuth.authorization_url(
- hexpm_url: "https://hex.pm",
- client_id: "client with spaces",
- redirect_uri: "https://example.com/callback?foo=bar",
- scope: "docs:org",
- state: "state&with=special",
- code_challenge: "abc123"
- )
-
- # URL should be properly encoded
- assert String.contains?(url, "client+with+spaces") or
- String.contains?(url, "client%20with%20spaces")
- end
- end
-
- describe "config/0" do
- test "returns keyword list with expected keys" do
- config = OAuth.config()
-
- assert Keyword.has_key?(config, :hexpm_url)
- assert Keyword.has_key?(config, :client_id)
- assert Keyword.has_key?(config, :client_secret)
- end
- end
-end
diff --git a/test/hexdocs/plug_gs_test.exs b/test/hexdocs/plug_gs_test.exs
deleted file mode 100644
index ff87da8..0000000
--- a/test/hexdocs/plug_gs_test.exs
+++ /dev/null
@@ -1,210 +0,0 @@
-defmodule Hexdocs.PlugGSTest do
- use ExUnit.Case, async: false
- import Plug.Conn
- import Plug.Test
- import Mox
-
- def fake_gs_auth, do: [{"authorization", "Bearer fake-token"}]
-
- # Mock GCS server that supports streaming
- defmodule MockGCSPlug do
- import Plug.Conn
-
- def init(opts), do: opts
-
- def call(conn, _opts) do
- case {conn.method, conn.path_info} do
- {"GET", ["hexdocs-private-test" | rest]} ->
- serve_file(conn, decode_path(rest))
-
- {"HEAD", ["hexdocs-private-test" | rest]} ->
- head_file(conn, decode_path(rest))
-
- {"PUT", ["hexdocs-private-test" | rest]} ->
- put_file(conn, decode_path(rest))
-
- {"DELETE", ["hexdocs-private-test" | rest]} ->
- delete_file(conn, decode_path(rest))
-
- _ ->
- send_resp(conn, 404, "Not Found")
- end
- end
-
- defp decode_path(segments) do
- segments
- |> Enum.map(&URI.decode/1)
- |> Enum.join("/")
- end
-
- defp serve_file(conn, path) do
- case :ets.lookup(:mock_gcs_files, path) do
- [{^path, content}] ->
- # Stream the content in chunks to test streaming
- conn = send_chunked(conn, 200)
-
- content
- |> chunk_binary(100)
- |> Enum.reduce(conn, fn chunk, conn ->
- {:ok, conn} = chunk(conn, chunk)
- conn
- end)
-
- [] ->
- send_resp(conn, 404, "Not Found")
- end
- end
-
- defp head_file(conn, path) do
- case :ets.lookup(:mock_gcs_files, path) do
- [{^path, _content}] -> send_resp(conn, 200, "")
- [] -> send_resp(conn, 404, "")
- end
- end
-
- defp put_file(conn, path) do
- {:ok, body, conn} = read_body(conn)
- :ets.insert(:mock_gcs_files, {path, body})
- send_resp(conn, 200, "")
- end
-
- defp delete_file(conn, path) do
- :ets.delete(:mock_gcs_files, path)
- send_resp(conn, 204, "")
- end
-
- defp chunk_binary(binary, chunk_size) do
- binary
- |> :binary.bin_to_list()
- |> Enum.chunk_every(chunk_size)
- |> Enum.map(&:binary.list_to_bin/1)
- end
- end
-
- setup do
- # Create ETS table for mock file storage
- if :ets.whereis(:mock_gcs_files) == :undefined do
- :ets.new(:mock_gcs_files, [:named_table, :public, :set])
- end
-
- :ets.delete_all_objects(:mock_gcs_files)
-
- # Start mock GCS server
- pid = start_supervised!({Bandit, plug: MockGCSPlug, scheme: :http, port: 0})
- {:ok, {_, port}} = ThousandIsland.listener_info(pid)
-
- # Configure to use GS store with mock server
- original_store_impl = Application.get_env(:hexdocs, :store_impl)
- original_gs_url = Application.get_env(:hexdocs, :gs_url)
- original_gs_auth = Application.get_env(:hexdocs, :gs_auth)
- original_bucket = Application.get_env(:hexdocs, :docs_private_bucket)
-
- Application.put_env(:hexdocs, :store_impl, Hexdocs.Store.Impl)
- Application.put_env(:hexdocs, :gs_url, "http://localhost:#{port}")
- Application.put_env(:hexdocs, :gs_auth, {__MODULE__, :fake_gs_auth})
-
- Application.put_env(:hexdocs, :docs_private_bucket,
- name: "hexdocs-private-test",
- implementation: Hexdocs.Store.GS
- )
-
- on_exit(fn ->
- Application.put_env(:hexdocs, :store_impl, original_store_impl)
-
- if original_gs_url do
- Application.put_env(:hexdocs, :gs_url, original_gs_url)
- else
- Application.delete_env(:hexdocs, :gs_url)
- end
-
- if original_gs_auth do
- Application.put_env(:hexdocs, :gs_auth, original_gs_auth)
- else
- Application.delete_env(:hexdocs, :gs_auth)
- end
-
- Application.put_env(:hexdocs, :docs_private_bucket, original_bucket)
- end)
-
- {:ok, port: port}
- end
-
- setup :verify_on_exit!
-
- describe "streaming through plug with GS store" do
- test "streams page content from GCS", %{test: test} do
- # Set up mock expectations
- Mox.expect(Hexdocs.HexpmMock, :verify_key, fn _token, _org -> :ok end)
-
- # Store a file in mock GCS
- content = String.duplicate("Hello from GCS! ", 100)
- path = "gstest/#{test}/index.html"
- :ets.insert(:mock_gcs_files, {path, content})
-
- # Make request through the plug
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
-
- conn =
- conn(:get, "http://gstest.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 == content
- end
-
- test "streams large file in chunks", %{test: test} do
- Mox.expect(Hexdocs.HexpmMock, :verify_key, fn _token, _org -> :ok end)
-
- # Create a larger file to ensure chunking works
- content = :crypto.strong_rand_bytes(5000) |> Base.encode64()
- path = "gstest/#{test}/large.html"
- :ets.insert(:mock_gcs_files, {path, content})
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
-
- conn =
- conn(:get, "http://gstest.localhost:5002/#{test}/large.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 == content
- end
-
- test "returns 404 for missing file", %{test: test} do
- Mox.expect(Hexdocs.HexpmMock, :verify_key, fn _token, _org -> :ok end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
-
- conn =
- conn(:get, "http://gstest.localhost:5002/#{test}/nonexistent.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 == 404
- end
- end
-
- defp call(conn) do
- Hexdocs.Plug.call(conn, Hexdocs.Plug.init([]))
- end
-end
diff --git a/test/hexdocs/plug_test.exs b/test/hexdocs/plug_test.exs
deleted file mode 100644
index 7098706..0000000
--- a/test/hexdocs/plug_test.exs
+++ /dev/null
@@ -1,411 +0,0 @@
-defmodule Hexdocs.PlugTest do
- use ExUnit.Case, async: true
- import Plug.Conn
- import Plug.Test
- import Mox
- alias Hexdocs.{HexpmMock, Store}
-
- setup :verify_on_exit!
-
- @bucket :docs_private_bucket
-
- test "bare private host apex redirects to hexpm_url" do
- # In the test env :host and :private_host are both "localhost", so a
- # bare-host request hits the private-host-apex redirect.
- conn = conn(:get, "http://localhost:5002/foo") |> call()
- assert conn.status == 301
- [location] = get_resp_header(conn, "location")
- assert location == "http://localhost:5000"
- end
-
- describe "OAuth flow" do
- test "redirect to OAuth authorize with no session" do
- conn = conn(:get, "http://plugtest.localhost:5002/foo") |> call()
- assert conn.status == 302
-
- [location] = get_resp_header(conn, "location")
- assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?")
-
- uri = URI.parse(location)
- query = URI.decode_query(uri.query)
-
- assert query["response_type"] == "code"
- assert query["client_id"] == "hexdocs"
- assert query["scope"] == "docs:plugtest"
- assert query["code_challenge_method"] == "S256"
- assert query["state"] != nil
- assert query["code_challenge"] != nil
-
- # Should store PKCE verifier and state in session
- assert get_session(conn, "oauth_code_verifier") != nil
- assert get_session(conn, "oauth_state") != nil
- assert get_session(conn, "oauth_return_path") == "/foo"
- end
-
- test "sanitize protocol-relative return path to prevent open redirect" do
- conn = conn(:get, "http://plugtest.localhost:5002//evil.com") |> call()
- assert conn.status == 302
- assert get_session(conn, "oauth_return_path") == "/"
- end
-
- test "sanitize protocol-relative return path with extra slashes" do
- conn = conn(:get, "http://plugtest.localhost:5002///evil.com/foo") |> call()
- assert conn.status == 302
- assert get_session(conn, "oauth_return_path") == "/"
- end
-
- test "preserve valid return path" do
- conn = conn(:get, "http://plugtest.localhost:5002/some/path") |> call()
- assert conn.status == 302
- assert get_session(conn, "oauth_return_path") == "/some/path"
- end
-
- test "OAuth callback with invalid state returns error" do
- conn =
- conn(:get, "http://plugtest.localhost:5002/oauth/callback?code=abc&state=wrong")
- |> init_test_session(%{
- "oauth_state" => "correct_state",
- "oauth_code_verifier" => "verifier"
- })
- |> call()
-
- assert conn.status == 403
- assert conn.resp_body =~ "Invalid OAuth state"
- end
-
- test "OAuth callback with missing code returns error" do
- conn =
- conn(:get, "http://plugtest.localhost:5002/oauth/callback?state=correct_state")
- |> init_test_session(%{
- "oauth_state" => "correct_state",
- "oauth_code_verifier" => "verifier"
- })
- |> call()
-
- assert conn.status == 400
- assert conn.resp_body =~ "Missing authorization code"
- end
-
- test "OAuth callback with error parameter returns error" do
- conn =
- conn(
- :get,
- "http://plugtest.localhost:5002/oauth/callback?error=access_denied&error_description=User%20denied"
- )
- |> init_test_session(%{"oauth_state" => "state", "oauth_code_verifier" => "verifier"})
- |> call()
-
- assert conn.status == 403
- assert conn.resp_body =~ "User denied"
- end
-
- test "serve page with valid OAuth token", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/index.html", "body")
-
- conn =
- conn(:get, "http://plugtest.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
-
- test "redirect to OAuth when token expired and no refresh token" do
- now = NaiveDateTime.utc_now()
- expired = NaiveDateTime.add(now, -1800, :second)
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/foo")
- |> init_test_session(%{
- "access_token" => "eyJhbGciOiJFUzI1NiJ9.test",
- "token_expires_at" => expired,
- "token_created_at" => NaiveDateTime.add(expired, -1800, :second)
- })
- |> call()
-
- assert conn.status == 302
- [location] = get_resp_header(conn, "location")
- assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?")
- end
- end
-
- describe "page serving with OAuth" do
- test "serve 200 page", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/index.html", "body")
-
- conn =
- conn(:get, "http://plugtest.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
-
- test "serve 404 page", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/index.html", "body")
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/#{test}/404.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 == 404
- assert conn.resp_body =~ "Page not found"
- end
-
- test "redirect to root", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/index.html", "body")
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/#{test}")
- |> init_test_session(%{
- "access_token" => "eyJhbGciOiJFUzI1NiJ9.test",
- "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh",
- "token_expires_at" => expires_at,
- "token_created_at" => now
- })
- |> call()
-
- assert conn.status == 302
- assert get_resp_header(conn, "location") == ["/#{test}/"]
- end
-
- test "serve index.html for root requests", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/index.html", "body")
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/#{test}/")
- |> 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
-
- test "serve docs_config.js for unversioned and versioned requests", %{test: test} do
- Mox.expect(HexpmMock, :verify_key, 2, fn token, organization ->
- assert String.starts_with?(token, "eyJ")
- assert organization == "plugtest"
- :ok
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
- Store.put!(@bucket, "plugtest/#{test}/docs_config.js", "var versionNodes;")
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/#{test}/docs_config.js")
- |> 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 == "var versionNodes;"
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/#{test}/1.0.0/docs_config.js")
- |> 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 == "var versionNodes;"
- end
-
- test "token verification fails redirects to OAuth" do
- Mox.expect(HexpmMock, :verify_key, fn _token, _organization ->
- {:error, "account not authorized"}
- end)
-
- now = NaiveDateTime.utc_now()
- expires_at = NaiveDateTime.add(now, 1800, :second)
-
- conn =
- conn(:get, "http://plugtest.localhost:5002/foo")
- |> init_test_session(%{
- "access_token" => "eyJhbGciOiJFUzI1NiJ9.test",
- "refresh_token" => "eyJhbGciOiJFUzI1NiJ9.refresh",
- "token_expires_at" => expires_at,
- "token_created_at" => now
- })
- |> call()
-
- assert conn.status == 403
- assert conn.resp_body =~ "account not authorized"
- end
-
- test "handle no path redirects to OAuth" do
- conn = conn(:get, "http://plugtest.localhost:5002/") |> call()
- assert conn.status == 302
-
- [location] = get_resp_header(conn, "location")
- assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?")
- end
- end
-
- describe "host handling" do
- setup do
- original_private_host = Application.get_env(:hexdocs, :private_host)
- Application.put_env(:hexdocs, :private_host, "hexorgs.test")
-
- on_exit(fn ->
- Application.put_env(:hexdocs, :private_host, original_private_host)
- end)
- end
-
- test "serves docs on private host" do
- conn = conn(:get, "http://myorg.hexorgs.test:5002/foo") |> call()
- assert conn.status == 302
-
- [location] = get_resp_header(conn, "location")
- assert String.starts_with?(location, "http://localhost:5000/oauth/authorize?")
- end
-
- test "returns 400 for unrecognized host" 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
-
- test "redirects bare private host apex to hexpm_url" do
- conn = conn(:get, "http://hexorgs.test:5002/") |> call()
- assert conn.status == 301
- [location] = get_resp_header(conn, "location")
- assert location == "http://localhost:5000"
- 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()
-
- assert get_resp_header(conn, "x-content-type-options") == ["nosniff"]
- assert get_resp_header(conn, "x-frame-options") == ["SAMEORIGIN"]
- assert get_resp_header(conn, "referrer-policy") == ["strict-origin-when-cross-origin"]
- end
-
- defp call(conn) do
- Hexdocs.Plug.call(conn, [])
- end
-end