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