4.0 KiB · text History 0d6bdf0
defmodule GitGudWeb.BadgeController do
  @moduledoc """
  A repository's status badges as images:

      /r/:owner/:name/badge/ci.svg
      /r/:owner/:name/badge/coverage/dot/2x/gg/forest.png
      /r/:owner/:name/badge/workflow/audit/outline.svg

  `GitGud.Badges.Spec` reads the path. The badge sheet
  (`GitGudWeb.RepoLive.Badges`) previews and builds these URLs.

  Visibility follows the repository, as for `RepoAuth`: a public or
  internal repository's badges are anyone's, a private one's only its
  readers', and a missing repository and a forbidden one both answer
  404. A package badge follows the registry's rule instead
  (`Packages.Auth`), so a private repository that publishes a format
  publicly has public badges for those packages. A badge is fetched by an `<img>`, which sends cookies on GitGud's
  own pages and nothing anywhere else, so a private repository's badges
  only work here.

  Caching is short: a badge reports state that changes with every push,
  and image proxies (GitHub's camo among them) honor `max-age`. The
  ETag lets a revalidation come back as a 304.
  """
  use GitGudWeb, :controller

  alias GitGud.Badges
  alias GitGud.Badges.Spec
  alias GitGud.Organizations
  alias GitGud.Packages
  alias GitGud.Repositories

  @max_age 120

  def show(conn, %{"owner" => owner, "name" => name, "path" => path}) do
    user = conn.assigns[:current_scope] && conn.assigns.current_scope.user

    with {:ok, spec} <- Spec.parse_path(path),
         repo when not is_nil(repo) <- Repositories.find_by_path(owner, name),
         true <- readable?(user, repo, spec),
         {:ok, content_type, body} <- Badges.render(repo, spec) do
      send_badge(conn, "#{cache_scope(repo, spec)}, max-age=#{@max_age}", content_type, body)
    else
      _ -> conn |> put_status(:not_found) |> text("")
    end
  end

  @doc """
  An example badge for the sheet: `/badges/sample/ci/failing/dot/forest.svg`.
  The examples are a fixed set and never change, so they cache for a day.
  """
  def sample(conn, %{"kind" => kind, "key" => key, "path" => path}) do
    # The options are parsed under a stand-in kind: what's drawn comes
    # from the sample, and `workflow` would want a name segment here.
    with {:ok, spec} <- Spec.parse_path(["mark" | path]),
         {:ok, content} <- Badges.sample(kind, key),
         {:ok, content_type, body} <- Badges.render_content(content, spec) do
      send_badge(conn, "public, max-age=86400", content_type, body)
    else
      _ -> conn |> put_status(:not_found) |> text("")
    end
  end

  defp readable?(user, repo, %Spec{kind: "package", registry: registry}) do
    principal = if user, do: {:user, user, :all}, else: :anonymous
    Packages.Auth.authorize(principal, repo, :read, registry) == true
  end

  defp readable?(user, repo, _spec), do: Organizations.can?(user, repo, :read) == true

  # What a stranger may see can be cached by anyone; the rest only by the
  # reader's own browser.
  defp cache_scope(repo, %Spec{kind: "package", registry: registry}) do
    if repo.visibility != "private" or Packages.Auth.public_format?(repo, registry),
      do: "public",
      else: "private"
  end

  defp cache_scope(repo, _spec),
    do: if(repo.visibility == "private", do: "private", else: "public")

  defp send_badge(conn, cache_control, content_type, body) do
    etag =
      ~s("#{:crypto.hash(:sha256, body) |> Base.url_encode64(padding: false) |> binary_part(0, 22)}")

    conn =
      conn
      |> put_resp_header("etag", etag)
      |> put_resp_header("cache-control", cache_control)
      |> put_resp_header("x-content-type-options", "nosniff")
      # The SVG is ours and carries no script, but it's served from the
      # app's origin; opening it directly shouldn't be able to run any.
      |> put_resp_header(
        "content-security-policy",
        "default-src 'none'; style-src 'unsafe-inline'"
      )

    if etag in get_req_header(conn, "if-none-match") do
      send_resp(conn, 304, "")
    else
      conn |> put_resp_content_type(content_type, nil) |> send_resp(200, body)
    end
  end
end