7.2 KiB · text History b868907
defmodule GitGud.Packages.Formats.Listing do
  @moduledoc """
  A browsable directory index for the registries that are plain file
  trees over HTTP.

  apt and dnf both consume a mirror by fetching exact paths, and both
  are conventionally *browsable* as well — `deb.debian.org/debian/` and
  `download.fedoraproject.org/.../os/` are autoindexes you can click
  down through. Ours only ever answered exact paths, so pointing a
  browser at a repository got a 404 and there was no way to see whether
  an upload landed without already knowing its name.

  No client is on this path; it exists for the person checking.

  The listing is derived from the paths the adapter says it serves,
  never from the filesystem or a separate enumeration — so it cannot
  advertise something that would 404, and it cannot leak a repository
  the caller couldn't already read, because the repository list was
  filtered before the adapter ever saw it.
  """

  @typedoc """
  One path the adapter answers to. `size` and `at` are optional, and
  are `nil` for generated index documents: sizing those would mean
  building all of them to draw a table.
  """
  @type entry :: %{path: String.t(), size: non_neg_integer() | nil, at: DateTime.t() | nil}

  @doc """
  Render the directory at `ctx.path`, given every path the repository
  serves.

  Returns `{:error, :not_found}` for an empty listing below the root —
  a path with no children is a path that doesn't exist, since the tree
  has no empty nodes in it.
  """
  @spec serve([entry()], String.t(), String.t(), map()) ::
          {:ok, String.t(), iodata()} | {:error, :not_found}
  def serve(paths, label, subtitle, %{path: prefix} = ctx) do
    case children(paths, prefix) do
      [] when prefix != [] -> {:error, :not_found}
      entries -> {:ok, "text/html; charset=utf-8", page(label, subtitle, ctx, prefix, entries)}
    end
  end

  # The immediate children of `prefix`: a path with exactly one segment
  # left is a file, more than one means a directory we've now seen.
  defp children(paths, prefix) do
    depth = length(prefix)

    paths
    |> Enum.flat_map(fn entry ->
      segments = String.split(entry.path, "/")

      if List.starts_with?(segments, prefix) do
        case Enum.drop(segments, depth) do
          [name] -> [%{name: name, dir?: false, size: entry.size, at: entry.at}]
          [name | _] -> [%{name: name, dir?: true, size: nil, at: nil}]
          [] -> []
        end
      else
        []
      end
    end)
    |> Enum.uniq_by(& &1.name)
    |> Enum.sort_by(&{not &1.dir?, &1.name})
  end

  # A plain document rather than the app layout — this is served from
  # the registry URL, which a browser may well reach with a Basic-auth
  # prompt and no session at all — but it still wears the forge's
  # colours.
  #
  # It pulls in `app.css` for the daisyUI theme definitions and repeats
  # the root layout's theme bootstrap, so `data-theme` is set before
  # first paint and the page matches whatever the viewer picked. Styling
  # then goes through the theme's own custom properties rather than
  # Tailwind utility classes: this module lives under `lib/git_gud`, and
  # `app.css` only scans `lib/git_gud_web`, so utility classes written
  # here would be purged out of the bundle and silently do nothing.
  #
  # The bootstrap is read-only — no picker on this page, so it never
  # writes `phx:theme` — but it does listen for `storage`, so changing
  # the theme in another tab recolours this one.
  defp page(label, subtitle, ctx, prefix, entries) do
    here = Enum.join([ctx.base_url | prefix], "/")

    rows =
      Enum.map(entries, fn entry ->
        href = here <> "/" <> URI.encode(entry.name) <> if(entry.dir?, do: "/", else: "")

        [
          "<tr><td><a class=\"",
          if(entry.dir?, do: "dir", else: "file"),
          "\" href=\"",
          esc(href),
          "\">",
          esc(entry.name),
          if(entry.dir?, do: "/", else: ""),
          "</a></td><td class=\"n\">",
          format_size(entry.size),
          "</td><td class=\"n\">",
          format_date(entry.at),
          "</td></tr>\n"
        ]
      end)

    parent =
      if prefix == [] do
        []
      else
        up = Enum.join([ctx.base_url | Enum.drop(prefix, -1)], "/") <> "/"
        ["<tr><td><a class=\"dir\" href=\"", esc(up), "\">../</a></td><td></td><td></td></tr>\n"]
      end

    title = "/" <> Enum.join(prefix, "/")

    [
      """
      <!DOCTYPE html>
      <html lang="en"><head><meta charset="utf-8">
      <meta name="viewport" content="width=device-width, initial-scale=1">
      <title>#{esc(label)} #{esc(title)}</title>
      <link rel="stylesheet" href="#{esc(stylesheet_path())}">
      <script>
        (() => {
          const SYSTEM_DARK = "#{GitGud.Themes.system_dark()}";
          const SYSTEM_LIGHT = "#{GitGud.Themes.system_light()}";
          const mq = window.matchMedia("(prefers-color-scheme: dark)");
          const systemTheme = () => mq.matches ? SYSTEM_DARK : SYSTEM_LIGHT;
          const apply = (theme) =>
            document.documentElement.setAttribute("data-theme", theme || systemTheme());
          apply(localStorage.getItem("phx:theme"));
          mq.addEventListener("change", () => {
            if (!localStorage.getItem("phx:theme")) apply(null);
          });
          window.addEventListener("storage", (e) => {
            if (e.key === "phx:theme") apply(e.newValue);
          });
        })();
      </script>
      <style>
        body {
          background: var(--color-base-100);
          color: var(--color-base-content);
          font-family: ui-monospace, "B612 Mono", SFMono-Regular, Menlo, monospace;
          font-size: 14px;
          line-height: 1.6;
          margin: 0 auto;
          max-width: 60rem;
          min-height: 100vh;
          padding: 2rem 1rem;
        }
        h1 { font-size: 1rem; font-weight: 600; margin: 0 0 .25rem; }
        h1 .path { opacity: .55; }
        p.sub { margin: 0 0 1.5rem; opacity: .6; font-size: .8rem; }
        table { border-collapse: collapse; width: 100%; }
        td { padding: .25rem .75rem .25rem 0; border-bottom: 1px solid var(--color-base-300); }
        td.n { text-align: right; white-space: nowrap; opacity: .6; font-size: .8rem; }
        a { color: inherit; text-decoration: none; }
        a:hover { text-decoration: underline; }
        a.dir { color: var(--color-primary); }
      </style></head><body>
      <h1>#{esc(label)}<span class="path">#{esc(title)}</span></h1>
      <p class="sub">#{esc(subtitle)}</p>
      <table>
      """,
      parent,
      rows,
      "</table></body></html>\n"
    ]
  end

  # Digest-aware, so the stylesheet resolves in a release the same way
  # the app layout's does.
  defp stylesheet_path, do: GitGudWeb.Endpoint.static_path("/assets/css/app.css")

  defp format_size(nil), do: ""
  defp format_size(bytes) when bytes < 1024, do: "#{bytes} B"
  defp format_size(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KB"
  defp format_size(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"

  defp format_date(nil), do: ""
  defp format_date(at), do: Calendar.strftime(at, "%Y-%m-%d %H:%M")

  defp esc(value) do
    value
    |> to_string()
    |> String.replace("&", "&amp;")
    |> String.replace("<", "&lt;")
    |> String.replace(">", "&gt;")
    |> String.replace("\"", "&quot;")
  end
end