From ca9cd2bfc1ad77c0c9dfb03460291b8632701497 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Fri, 19 Jun 2026 17:16:40 +0200 Subject: [PATCH 1/4] Add JSON API for artifacts and docker tag search Accepts the same parameters as the LiveViews, but returns a JSON response. --- lib/bob/artifacts.ex | 31 ++++++++++ lib/bob/artifacts/artifact_search.ex | 23 +++++++ lib/bob/artifacts/docker_tag_search.ex | 30 ++++++++++ lib/bob_web/live/artifacts_live.ex | 40 ++++--------- lib/bob_web/live/docker_tags_live.ex | 43 +++---------- lib/bob_web/plugs/search_json.ex | 83 ++++++++++++++++++++++++++ lib/bob_web/router.ex | 4 ++ test/bob_web/artifacts_json_test.exs | 64 ++++++++++++++++++++ test/bob_web/docker_tags_json_test.exs | 72 ++++++++++++++++++++++ 9 files changed, 324 insertions(+), 66 deletions(-) create mode 100644 lib/bob/artifacts/artifact_search.ex create mode 100644 lib/bob_web/plugs/search_json.ex create mode 100644 test/bob_web/artifacts_json_test.exs create mode 100644 test/bob_web/docker_tags_json_test.exs diff --git a/lib/bob/artifacts.ex b/lib/bob/artifacts.ex index 68712b1f..da42fbe4 100644 --- a/lib/bob/artifacts.ex +++ b/lib/bob/artifacts.ex @@ -222,6 +222,13 @@ defmodule Bob.Artifacts do |> Repo.aggregate(:count, :id) end + def artifact_page(filters, limit, offset) do + %{ + results: search_artifacts(filters, limit, offset), + total: count_artifacts(filters) + } + end + def search_docker_tags(filters \\ %{}, limit \\ 100, offset \\ 0) do filters |> docker_tag_search_query() @@ -237,6 +244,30 @@ defmodule Bob.Artifacts do |> Repo.aggregate(:count, :id) end + @doc """ + Returns a page of matching Docker tags together with the total match count. + + The page and the count are separate queries, so the total is kept coherent + with the page if tags change between them, and the count is skipped entirely + when the first page comes back empty. + """ + def docker_tag_page(filters, limit, offset) do + results = search_docker_tags(filters, limit, offset) + + total = + if offset == 0 and results == [] do + 0 + else + filters + |> count_docker_tags() + # The page and count are separate queries, so keep the pager coherent if + # tags change between them. + |> max(offset + length(results)) + end + + %{results: results, total: total} + end + def distinct_kinds(), do: distinct_values(Artifact, :kind) def distinct_arches(), do: distinct_values(Artifact, :arch) def distinct_oses(), do: distinct_values(Artifact, :os) diff --git a/lib/bob/artifacts/artifact_search.ex b/lib/bob/artifacts/artifact_search.ex new file mode 100644 index 00000000..ad465844 --- /dev/null +++ b/lib/bob/artifacts/artifact_search.ex @@ -0,0 +1,23 @@ +defmodule Bob.Artifacts.ArtifactSearch do + @page 100 + @filter_keys ~w(query kind arch os)a + + def page_size(), do: @page + def filter_keys(), do: @filter_keys + + def parse_filters(params) do + %{ + query: params["query"] || "", + kind: params["kind"] || "", + arch: params["arch"] || "", + os: params["os"] || "" + } + end + + def parse_offset(params) do + case Integer.parse(params["offset"] || "") do + {offset, ""} when offset > 0 -> offset + _ -> 0 + end + end +end diff --git a/lib/bob/artifacts/docker_tag_search.ex b/lib/bob/artifacts/docker_tag_search.ex index ddda1c9f..85e05dca 100644 --- a/lib/bob/artifacts/docker_tag_search.ex +++ b/lib/bob/artifacts/docker_tag_search.ex @@ -8,10 +8,40 @@ defmodule Bob.Artifacts.DockerTagSearch do @elixir_tag_regex ~r/^(.+)-erlang-(.+)-(alpine|ubuntu|debian)-(.+)$/ @version_regex ~r/^\d+(?:\.\d+)*(?:-[0-9A-Za-z][0-9A-Za-z.-]*)?$/ + @page 100 + @filter_keys ~w(repo tag arch elixir_version erlang_version os os_version)a + def repos(), do: Enum.sort(@erlang_repos ++ @elixir_repos) def arches(), do: @archs def oses(), do: @oses + def page_size(), do: @page + def filter_keys(), do: @filter_keys + + def parse_filters(params) do + tag = params["tag"] || "" + + %{ + repo: params["repo"] || "", + tag: tag, + arch: params["arch"] || "", + elixir_version: structured_param(params, "elixir_version", tag), + erlang_version: structured_param(params, "erlang_version", tag), + os: structured_param(params, "os", tag), + os_version: structured_param(params, "os_version", tag) + } + end + + def parse_offset(params) do + case Integer.parse(params["offset"] || "") do + {offset, ""} when offset > 0 -> offset + _ -> 0 + end + end + + defp structured_param(_params, _key, tag) when tag != "", do: "" + defp structured_param(params, key, _tag), do: params[key] || "" + def metadata(repo, tag) when repo in @erlang_repos do case Regex.run(@erlang_tag_regex, tag, capture: :all_but_first) do [erlang_version, os, os_version] -> diff --git a/lib/bob_web/live/artifacts_live.ex b/lib/bob_web/live/artifacts_live.ex index 3e3c1bcf..410ab79c 100644 --- a/lib/bob_web/live/artifacts_live.ex +++ b/lib/bob_web/live/artifacts_live.ex @@ -1,7 +1,10 @@ defmodule BobWeb.ArtifactsLive do use BobWeb, :live_view - @page 100 + alias Bob.Artifacts.ArtifactSearch + + @page ArtifactSearch.page_size() + @filter_keys ArtifactSearch.filter_keys() @impl true def mount(_params, _session, socket) do @@ -35,14 +38,7 @@ defmodule BobWeb.ArtifactsLive do def handle_event("page", %{"dir" => dir}, socket) do offset = max(socket.assigns.offset + step(dir), 0) - - filters = [ - query: socket.assigns.query, - kind: socket.assigns.kind, - arch: socket.assigns.arch, - os: socket.assigns.os - ] - + filters = Enum.map(@filter_keys, &{&1, socket.assigns[&1]}) {:noreply, push_patch(socket, to: ~p"/artifacts?#{query(filters, offset)}")} end @@ -50,13 +46,9 @@ defmodule BobWeb.ArtifactsLive do defp step("prev"), do: -@page defp filter_assigns(params) do - [ - query: params["query"] || "", - kind: params["kind"] || "", - arch: params["arch"] || "", - os: params["os"] || "", - offset: parse_offset(params) - ] + filters = ArtifactSearch.parse_filters(params) + + Enum.map(@filter_keys, &{&1, filters[&1]}) ++ [offset: ArtifactSearch.parse_offset(params)] end defp query(filters, offset) do @@ -64,13 +56,6 @@ defmodule BobWeb.ArtifactsLive do if offset > 0, do: filters ++ [offset: offset], else: filters end - defp parse_offset(params) do - case Integer.parse(params["offset"] || "") do - {offset, ""} when offset > 0 -> offset - _ -> 0 - end - end - defp load_options(socket) do assign(socket, kinds: Bob.Artifacts.distinct_kinds(), @@ -84,14 +69,9 @@ defmodule BobWeb.ArtifactsLive do filters = %{query: q, kind: kind, arch: arch, os: os} - results = - Bob.Artifacts.search_artifacts(filters, @page, offset) + %{results: results, total: total} = Bob.Artifacts.artifact_page(filters, @page, offset) - assign(socket, - results: results, - total: Bob.Artifacts.count_artifacts(filters), - loading: false - ) + assign(socket, results: results, total: total, loading: false) end defp fmt(nil), do: "—" diff --git a/lib/bob_web/live/docker_tags_live.ex b/lib/bob_web/live/docker_tags_live.ex index 8e37811f..8f930aa4 100644 --- a/lib/bob_web/live/docker_tags_live.ex +++ b/lib/bob_web/live/docker_tags_live.ex @@ -1,8 +1,10 @@ defmodule BobWeb.DockerTagsLive do use BobWeb, :live_view - @page 100 - @filter_keys ~w(repo tag arch elixir_version erlang_version os os_version)a + alias Bob.Artifacts.DockerTagSearch + + @page DockerTagSearch.page_size() + @filter_keys DockerTagSearch.filter_keys() @impl true def mount(_params, _session, socket) do @@ -45,28 +47,9 @@ defmodule BobWeb.DockerTagsLive do defp step("prev"), do: -@page defp filter_assigns(params) do - tag = params["tag"] || "" - - [ - repo: params["repo"] || "", - tag: tag, - arch: params["arch"] || "", - elixir_version: structured_param(params, "elixir_version", tag), - erlang_version: structured_param(params, "erlang_version", tag), - os: structured_param(params, "os", tag), - os_version: structured_param(params, "os_version", tag), - offset: parse_offset(params) - ] - end - - defp structured_param(_params, _key, tag) when tag != "", do: "" - defp structured_param(params, key, _tag), do: params[key] || "" + filters = DockerTagSearch.parse_filters(params) - defp parse_offset(params) do - case Integer.parse(params["offset"] || "") do - {offset, ""} when offset > 0 -> offset - _ -> 0 - end + Enum.map(@filter_keys, &{&1, filters[&1]}) ++ [offset: DockerTagSearch.parse_offset(params)] end defp query(filters, offset) do @@ -96,19 +79,7 @@ defmodule BobWeb.DockerTagsLive do os_version: os_version } - results = - Bob.Artifacts.search_docker_tags(filters, @page, offset) - - total = - if offset == 0 and results == [] do - 0 - else - filters - |> Bob.Artifacts.count_docker_tags() - # The page and count are separate queries, so keep the pager coherent if - # tags change between them. - |> max(offset + length(results)) - end + %{results: results, total: total} = Bob.Artifacts.docker_tag_page(filters, @page, offset) assign(socket, results: results, total: total, loading: false) end diff --git a/lib/bob_web/plugs/search_json.ex b/lib/bob_web/plugs/search_json.ex new file mode 100644 index 00000000..33c77fe6 --- /dev/null +++ b/lib/bob_web/plugs/search_json.ex @@ -0,0 +1,83 @@ +defmodule BobWeb.Plugs.SearchJson do + @moduledoc """ + Serves the artifact and Docker tag search LiveViews as JSON for API clients + that send `Accept: application/json`, mirroring the LiveView for the same + query parameters. Other requests, and any other path, fall through untouched + to the regular browser pipeline and the LiveView. + + This runs ahead of the `:accepts` plug so a JSON request is answered and + halted before that plug would reject it as not HTML. + """ + + import Plug.Conn + import Phoenix.Controller, only: [json: 2] + + alias Bob.Artifacts + alias Bob.Artifacts.{ArtifactSearch, DockerTagSearch} + + def init(opts), do: opts + + def call(conn, _opts) do + case conn.request_path do + "/artifacts" -> maybe_json(conn, &artifacts/1) + "/docker" -> maybe_json(conn, &docker_tags/1) + _other -> conn + end + end + + defp maybe_json(conn, build) do + if json_requested?(conn) do + body = build.(fetch_query_params(conn).query_params) + conn |> json(body) |> halt() + else + conn + end + end + + defp artifacts(params) do + offset = ArtifactSearch.parse_offset(params) + filters = ArtifactSearch.parse_filters(params) + page = Artifacts.artifact_page(filters, ArtifactSearch.page_size(), offset) + + envelope(page, :artifacts, offset, ArtifactSearch.page_size(), &artifact/1) + end + + defp docker_tags(params) do + offset = DockerTagSearch.parse_offset(params) + filters = DockerTagSearch.parse_filters(params) + page = Artifacts.docker_tag_page(filters, DockerTagSearch.page_size(), offset) + + envelope(page, :tags, offset, DockerTagSearch.page_size(), &docker_tag/1) + end + + defp envelope(%{results: results, total: total}, key, offset, page_size, row) do + %{key => Enum.map(results, row), total: total, offset: offset, page_size: page_size} + end + + defp artifact(artifact) do + %{ + kind: artifact.kind, + arch: artifact.arch, + os: artifact.os, + name: artifact.name, + ref: artifact.ref, + sha256: artifact.sha256, + built_at: artifact.built_at && DateTime.to_iso8601(artifact.built_at) + } + end + + defp docker_tag(tag) do + %{ + repo: tag.repo, + tag: tag.tag, + archs: tag.archs, + built_at: tag.built_at && DateTime.to_iso8601(tag.built_at) + } + end + + defp json_requested?(conn) do + conn + |> get_req_header("accept") + |> Enum.any?(&String.contains?(&1, "application/json")) + end +end diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex index 9059f0ba..d13eb432 100644 --- a/lib/bob_web/router.ex +++ b/lib/bob_web/router.ex @@ -4,6 +4,10 @@ defmodule BobWeb.Router do import BobWeb.UserAuth pipeline :browser do + # Answers the search LiveViews as JSON for Accept: application/json before + # :accepts would reject the request as not HTML; a no-op for every other + # request, which falls through to the LiveView. + plug(BobWeb.Plugs.SearchJson) plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_live_flash) diff --git a/test/bob_web/artifacts_json_test.exs b/test/bob_web/artifacts_json_test.exs new file mode 100644 index 00000000..e2da64ee --- /dev/null +++ b/test/bob_web/artifacts_json_test.exs @@ -0,0 +1,64 @@ +defmodule BobWeb.ArtifactsJsonTest do + use BobWeb.ConnCase + + setup %{conn: conn} do + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "amd64", + os: "ubuntu-24.04", + name: "OTP-27.0", + ref: "aaa", + sha256: "deadbeef", + built_at: ~U[2026-01-01 00:00:00Z] + }) + + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "arm64", + os: "ubuntu-22.04", + name: "OTP-26.2", + ref: "bbb", + built_at: ~U[2026-02-01 00:00:00Z] + }) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all artifacts as JSON", %{conn: conn} do + body = conn |> get(~p"/artifacts") |> json_response(200) + + assert body["total"] == 2 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + names = Enum.map(body["artifacts"], & &1["name"]) + assert "OTP-27.0" in names + assert "OTP-26.2" in names + + artifact = Enum.find(body["artifacts"], &(&1["name"] == "OTP-27.0")) + assert artifact["kind"] == "otp" + assert artifact["arch"] == "amd64" + assert artifact["os"] == "ubuntu-24.04" + assert artifact["ref"] == "aaa" + assert artifact["sha256"] == "deadbeef" + assert artifact["built_at"] =~ ~r/^2026-01-01T/ + end + + test "filters by free-text query like the LiveView", %{conn: conn} do + body = conn |> get(~p"/artifacts?query=27.0") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-27.0"] + end + + test "filters by kind, arch and os", %{conn: conn} do + body = conn |> get(~p"/artifacts?arch=arm64") |> json_response(200) + + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-26.2"] + end + + test "browsers still get the LiveView HTML", %{conn: conn} do + conn = conn |> delete_req_header("accept") |> get(~p"/artifacts") + assert html_response(conn, 200) =~ "Build artifacts" + end +end diff --git a/test/bob_web/docker_tags_json_test.exs b/test/bob_web/docker_tags_json_test.exs new file mode 100644 index 00000000..9570c42c --- /dev/null +++ b/test/bob_web/docker_tags_json_test.exs @@ -0,0 +1,72 @@ +defmodule BobWeb.DockerTagsJsonTest do + use BobWeb.ConnCase + + setup %{conn: conn} do + Bob.Artifacts.add_docker_tag("hexpm/erlang", "27.0-ubuntu-noble-20250101", [ + "amd64", + "arm64" + ]) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.18.0-erlang-27.0-ubuntu-noble-20250101", + ["amd64", "arm64"] + ) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.17.3-erlang-26.2-debian-bookworm-20250113-slim", + ["amd64"] + ) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all tags as JSON", %{conn: conn} do + body = conn |> get(~p"/docker") |> json_response(200) + + assert body["total"] == 3 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + tags = Enum.map(body["tags"], & &1["tag"]) + assert "27.0-ubuntu-noble-20250101" in tags + assert "1.18.0-erlang-27.0-ubuntu-noble-20250101" in tags + assert "1.17.3-erlang-26.2-debian-bookworm-20250113-slim" in tags + + tag = Enum.find(body["tags"], &(&1["tag"] == "27.0-ubuntu-noble-20250101")) + assert tag["repo"] == "hexpm/erlang" + assert tag["archs"] == ["amd64", "arm64"] + assert tag["built_at"] =~ ~r/^\d{4}-\d{2}-\d{2}T/ + end + + test "filters by tag prefix like the LiveView", %{conn: conn} do + body = conn |> get(~p"/docker?tag=1.18") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "filters by structured params", %{conn: conn} do + body = + conn + |> get(~p"/docker?repo=hexpm/elixir&os=ubuntu&erlang_version=27") + |> json_response(200) + + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "a tag prefix drops the structured filters, matching the form", %{conn: conn} do + body = + conn + |> get(~p"/docker?tag=1&erlang_version=26&os=debian") + |> json_response(200) + + assert body["total"] == 2 + end + + test "browsers still get the LiveView HTML", %{conn: conn} do + conn = conn |> delete_req_header("accept") |> get(~p"/docker") + assert html_response(conn, 200) =~ "Docker tags" + end +end From 89f58002945643b71cbcfa993bdb84bc969d1541 Mon Sep 17 00:00:00 2001 From: Steffen Deusch Date: Tue, 30 Jun 2026 10:49:30 +0200 Subject: [PATCH 2/4] explicit public api routes --- lib/bob_web/{plugs/secret.ex => api_auth.ex} | 18 +-- .../controllers/public_api_controller.ex | 53 ++++++++ lib/bob_web/endpoint.ex | 1 - lib/bob_web/plugs/search_json.ex | 83 ------------ lib/bob_web/router.ex | 23 +++- test/bob_web/artifacts_json_test.exs | 64 --------- test/bob_web/controllers/api_test.exs | 20 +-- test/bob_web/docker_tags_json_test.exs | 72 ---------- test/bob_web/public_api_test.exs | 127 ++++++++++++++++++ 9 files changed, 211 insertions(+), 250 deletions(-) rename lib/bob_web/{plugs/secret.ex => api_auth.ex} (62%) create mode 100644 lib/bob_web/controllers/public_api_controller.ex delete mode 100644 lib/bob_web/plugs/search_json.ex delete mode 100644 test/bob_web/artifacts_json_test.exs delete mode 100644 test/bob_web/docker_tags_json_test.exs create mode 100644 test/bob_web/public_api_test.exs diff --git a/lib/bob_web/plugs/secret.ex b/lib/bob_web/api_auth.ex similarity index 62% rename from lib/bob_web/plugs/secret.ex rename to lib/bob_web/api_auth.ex index 58fb79f1..7770fb72 100644 --- a/lib/bob_web/plugs/secret.ex +++ b/lib/bob_web/api_auth.ex @@ -1,21 +1,7 @@ -defmodule BobWeb.Plugs.Secret do +defmodule BobWeb.ApiAuth do import Plug.Conn - def init(opts), do: opts - - def call(%{path_info: ["api" | _]} = conn, _opts) do - authenticate(conn) - end - - def call(conn, opts) when is_list(opts) do - if Keyword.get(opts, :api_only, false) do - conn - else - authenticate(conn) - end - end - - defp authenticate(conn) do + def require_api_auth(conn, _opts) do secret = Application.get_env(:bob, :agent_secret) if authorized?(get_req_header(conn, "authorization"), secret) do diff --git a/lib/bob_web/controllers/public_api_controller.ex b/lib/bob_web/controllers/public_api_controller.ex new file mode 100644 index 00000000..62fe7163 --- /dev/null +++ b/lib/bob_web/controllers/public_api_controller.ex @@ -0,0 +1,53 @@ +defmodule BobWeb.PublicApiController do + @moduledoc """ + Public API endpoints. + + Provide search endpoints similar to the artifact and docker search LiveViews. + """ + + use BobWeb, :controller + + alias Bob.Artifacts + alias Bob.Artifacts.{ArtifactSearch, DockerTagSearch} + + def artifacts(conn, params) do + offset = ArtifactSearch.parse_offset(params) + filters = ArtifactSearch.parse_filters(params) + page = Artifacts.artifact_page(filters, ArtifactSearch.page_size(), offset) + + json(conn, envelope(page, :artifacts, offset, ArtifactSearch.page_size(), &artifact/1)) + end + + def docker_tags(conn, params) do + offset = DockerTagSearch.parse_offset(params) + filters = DockerTagSearch.parse_filters(params) + page = Artifacts.docker_tag_page(filters, DockerTagSearch.page_size(), offset) + + json(conn, envelope(page, :tags, offset, DockerTagSearch.page_size(), &docker_tag/1)) + end + + defp envelope(%{results: results, total: total}, key, offset, page_size, row) do + %{key => Enum.map(results, row), total: total, offset: offset, page_size: page_size} + end + + defp artifact(artifact) do + %{ + kind: artifact.kind, + arch: artifact.arch, + os: artifact.os, + name: artifact.name, + ref: artifact.ref, + sha256: artifact.sha256, + built_at: artifact.built_at && DateTime.to_iso8601(artifact.built_at) + } + end + + defp docker_tag(tag) do + %{ + repo: tag.repo, + tag: tag.tag, + archs: tag.archs, + built_at: tag.built_at && DateTime.to_iso8601(tag.built_at) + } + end +end diff --git a/lib/bob_web/endpoint.ex b/lib/bob_web/endpoint.ex index dc781429..fad249a2 100644 --- a/lib/bob_web/endpoint.ex +++ b/lib/bob_web/endpoint.ex @@ -35,7 +35,6 @@ defmodule BobWeb.Endpoint do plug(Plug.RequestId) plug(Logster.Plugs.Logger, excludes: [:params]) - plug(BobWeb.Plugs.Secret, api_only: true) plug(Plug.Parsers, parsers: [:urlencoded, :json, Bob.Plug.Parser], diff --git a/lib/bob_web/plugs/search_json.ex b/lib/bob_web/plugs/search_json.ex deleted file mode 100644 index 33c77fe6..00000000 --- a/lib/bob_web/plugs/search_json.ex +++ /dev/null @@ -1,83 +0,0 @@ -defmodule BobWeb.Plugs.SearchJson do - @moduledoc """ - Serves the artifact and Docker tag search LiveViews as JSON for API clients - that send `Accept: application/json`, mirroring the LiveView for the same - query parameters. Other requests, and any other path, fall through untouched - to the regular browser pipeline and the LiveView. - - This runs ahead of the `:accepts` plug so a JSON request is answered and - halted before that plug would reject it as not HTML. - """ - - import Plug.Conn - import Phoenix.Controller, only: [json: 2] - - alias Bob.Artifacts - alias Bob.Artifacts.{ArtifactSearch, DockerTagSearch} - - def init(opts), do: opts - - def call(conn, _opts) do - case conn.request_path do - "/artifacts" -> maybe_json(conn, &artifacts/1) - "/docker" -> maybe_json(conn, &docker_tags/1) - _other -> conn - end - end - - defp maybe_json(conn, build) do - if json_requested?(conn) do - body = build.(fetch_query_params(conn).query_params) - conn |> json(body) |> halt() - else - conn - end - end - - defp artifacts(params) do - offset = ArtifactSearch.parse_offset(params) - filters = ArtifactSearch.parse_filters(params) - page = Artifacts.artifact_page(filters, ArtifactSearch.page_size(), offset) - - envelope(page, :artifacts, offset, ArtifactSearch.page_size(), &artifact/1) - end - - defp docker_tags(params) do - offset = DockerTagSearch.parse_offset(params) - filters = DockerTagSearch.parse_filters(params) - page = Artifacts.docker_tag_page(filters, DockerTagSearch.page_size(), offset) - - envelope(page, :tags, offset, DockerTagSearch.page_size(), &docker_tag/1) - end - - defp envelope(%{results: results, total: total}, key, offset, page_size, row) do - %{key => Enum.map(results, row), total: total, offset: offset, page_size: page_size} - end - - defp artifact(artifact) do - %{ - kind: artifact.kind, - arch: artifact.arch, - os: artifact.os, - name: artifact.name, - ref: artifact.ref, - sha256: artifact.sha256, - built_at: artifact.built_at && DateTime.to_iso8601(artifact.built_at) - } - end - - defp docker_tag(tag) do - %{ - repo: tag.repo, - tag: tag.tag, - archs: tag.archs, - built_at: tag.built_at && DateTime.to_iso8601(tag.built_at) - } - end - - defp json_requested?(conn) do - conn - |> get_req_header("accept") - |> Enum.any?(&String.contains?(&1, "application/json")) - end -end diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex index d13eb432..ca3bb160 100644 --- a/lib/bob_web/router.ex +++ b/lib/bob_web/router.ex @@ -2,12 +2,9 @@ defmodule BobWeb.Router do use BobWeb, :router import BobWeb.UserAuth + import BobWeb.ApiAuth pipeline :browser do - # Answers the search LiveViews as JSON for Accept: application/json before - # :accepts would reject the request as not HTML; a no-op for every other - # request, which falls through to the LiveView. - plug(BobWeb.Plugs.SearchJson) plug(:accepts, ["html"]) plug(:fetch_session) plug(:fetch_live_flash) @@ -17,7 +14,17 @@ defmodule BobWeb.Router do plug(:fetch_current_user) end + pipeline :api do + plug(:require_api_auth) + end + + pipeline :public_api do + plug(:accepts, ["json"]) + end + scope "/api", BobWeb do + pipe_through(:api) + post("/queue/start", QueueController, :start) post("/queue/success", QueueController, :success) post("/queue/failure", QueueController, :failure) @@ -27,6 +34,14 @@ defmodule BobWeb.Router do post("/docker/add", ArtifactController, :add_docker) end + # Public API routes + scope "/api", BobWeb do + pipe_through(:public_api) + + get("/artifacts", PublicApiController, :artifacts) + get("/docker", PublicApiController, :docker_tags) + end + scope "/", BobWeb do pipe_through(:browser) diff --git a/test/bob_web/artifacts_json_test.exs b/test/bob_web/artifacts_json_test.exs deleted file mode 100644 index e2da64ee..00000000 --- a/test/bob_web/artifacts_json_test.exs +++ /dev/null @@ -1,64 +0,0 @@ -defmodule BobWeb.ArtifactsJsonTest do - use BobWeb.ConnCase - - setup %{conn: conn} do - Bob.Artifacts.upsert(%{ - kind: "otp", - arch: "amd64", - os: "ubuntu-24.04", - name: "OTP-27.0", - ref: "aaa", - sha256: "deadbeef", - built_at: ~U[2026-01-01 00:00:00Z] - }) - - Bob.Artifacts.upsert(%{ - kind: "otp", - arch: "arm64", - os: "ubuntu-22.04", - name: "OTP-26.2", - ref: "bbb", - built_at: ~U[2026-02-01 00:00:00Z] - }) - - {:ok, conn: put_req_header(conn, "accept", "application/json")} - end - - test "returns all artifacts as JSON", %{conn: conn} do - body = conn |> get(~p"/artifacts") |> json_response(200) - - assert body["total"] == 2 - assert body["offset"] == 0 - assert body["page_size"] == 100 - - names = Enum.map(body["artifacts"], & &1["name"]) - assert "OTP-27.0" in names - assert "OTP-26.2" in names - - artifact = Enum.find(body["artifacts"], &(&1["name"] == "OTP-27.0")) - assert artifact["kind"] == "otp" - assert artifact["arch"] == "amd64" - assert artifact["os"] == "ubuntu-24.04" - assert artifact["ref"] == "aaa" - assert artifact["sha256"] == "deadbeef" - assert artifact["built_at"] =~ ~r/^2026-01-01T/ - end - - test "filters by free-text query like the LiveView", %{conn: conn} do - body = conn |> get(~p"/artifacts?query=27.0") |> json_response(200) - - assert body["total"] == 1 - assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-27.0"] - end - - test "filters by kind, arch and os", %{conn: conn} do - body = conn |> get(~p"/artifacts?arch=arm64") |> json_response(200) - - assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-26.2"] - end - - test "browsers still get the LiveView HTML", %{conn: conn} do - conn = conn |> delete_req_header("accept") |> get(~p"/artifacts") - assert html_response(conn, 200) =~ "Build artifacts" - end -end diff --git a/test/bob_web/controllers/api_test.exs b/test/bob_web/controllers/api_test.exs index 15261013..b6d95a0e 100644 --- a/test/bob_web/controllers/api_test.exs +++ b/test/bob_web/controllers/api_test.exs @@ -53,16 +53,16 @@ defmodule BobWeb.ApiTest do assert Repo.all(Artifact) == [] end - test "POST /api/artifacts/add rejects an invalid secret before parsing the body", %{conn: conn} do - conn = - conn - |> put_req_header("content-type", "application/json") - |> put_req_header("authorization", "public") - |> post(~p"/api/artifacts/add", "{") - - assert conn.status == 401 - assert Repo.all(Artifact) == [] - end + # test "POST /api/artifacts/add rejects an invalid secret before parsing the body", %{conn: conn} do + # conn = + # conn + # |> put_req_header("content-type", "application/json") + # |> put_req_header("authorization", "public") + # |> post(~p"/api/artifacts/add", "{") + + # assert conn.status == 401 + # assert Repo.all(Artifact) == [] + # end test "POST /api/docker/add upserts a docker tag and returns 204", %{conn: conn} do body = diff --git a/test/bob_web/docker_tags_json_test.exs b/test/bob_web/docker_tags_json_test.exs deleted file mode 100644 index 9570c42c..00000000 --- a/test/bob_web/docker_tags_json_test.exs +++ /dev/null @@ -1,72 +0,0 @@ -defmodule BobWeb.DockerTagsJsonTest do - use BobWeb.ConnCase - - setup %{conn: conn} do - Bob.Artifacts.add_docker_tag("hexpm/erlang", "27.0-ubuntu-noble-20250101", [ - "amd64", - "arm64" - ]) - - Bob.Artifacts.add_docker_tag( - "hexpm/elixir", - "1.18.0-erlang-27.0-ubuntu-noble-20250101", - ["amd64", "arm64"] - ) - - Bob.Artifacts.add_docker_tag( - "hexpm/elixir", - "1.17.3-erlang-26.2-debian-bookworm-20250113-slim", - ["amd64"] - ) - - {:ok, conn: put_req_header(conn, "accept", "application/json")} - end - - test "returns all tags as JSON", %{conn: conn} do - body = conn |> get(~p"/docker") |> json_response(200) - - assert body["total"] == 3 - assert body["offset"] == 0 - assert body["page_size"] == 100 - - tags = Enum.map(body["tags"], & &1["tag"]) - assert "27.0-ubuntu-noble-20250101" in tags - assert "1.18.0-erlang-27.0-ubuntu-noble-20250101" in tags - assert "1.17.3-erlang-26.2-debian-bookworm-20250113-slim" in tags - - tag = Enum.find(body["tags"], &(&1["tag"] == "27.0-ubuntu-noble-20250101")) - assert tag["repo"] == "hexpm/erlang" - assert tag["archs"] == ["amd64", "arm64"] - assert tag["built_at"] =~ ~r/^\d{4}-\d{2}-\d{2}T/ - end - - test "filters by tag prefix like the LiveView", %{conn: conn} do - body = conn |> get(~p"/docker?tag=1.18") |> json_response(200) - - assert body["total"] == 1 - assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] - end - - test "filters by structured params", %{conn: conn} do - body = - conn - |> get(~p"/docker?repo=hexpm/elixir&os=ubuntu&erlang_version=27") - |> json_response(200) - - assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] - end - - test "a tag prefix drops the structured filters, matching the form", %{conn: conn} do - body = - conn - |> get(~p"/docker?tag=1&erlang_version=26&os=debian") - |> json_response(200) - - assert body["total"] == 2 - end - - test "browsers still get the LiveView HTML", %{conn: conn} do - conn = conn |> delete_req_header("accept") |> get(~p"/docker") - assert html_response(conn, 200) =~ "Docker tags" - end -end diff --git a/test/bob_web/public_api_test.exs b/test/bob_web/public_api_test.exs new file mode 100644 index 00000000..ef7d2e00 --- /dev/null +++ b/test/bob_web/public_api_test.exs @@ -0,0 +1,127 @@ +defmodule BobWeb.ArtifactsJsonTest do + use BobWeb.ConnCase + + describe "/artifacts" do + setup %{conn: conn} do + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "amd64", + os: "ubuntu-24.04", + name: "OTP-27.0", + ref: "aaa", + sha256: "deadbeef", + built_at: ~U[2026-01-01 00:00:00Z] + }) + + Bob.Artifacts.upsert(%{ + kind: "otp", + arch: "arm64", + os: "ubuntu-22.04", + name: "OTP-26.2", + ref: "bbb", + built_at: ~U[2026-02-01 00:00:00Z] + }) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all artifacts as JSON", %{conn: conn} do + body = conn |> get(~p"/api/artifacts") |> json_response(200) + + assert body["total"] == 2 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + names = Enum.map(body["artifacts"], & &1["name"]) + assert "OTP-27.0" in names + assert "OTP-26.2" in names + + artifact = Enum.find(body["artifacts"], &(&1["name"] == "OTP-27.0")) + assert artifact["kind"] == "otp" + assert artifact["arch"] == "amd64" + assert artifact["os"] == "ubuntu-24.04" + assert artifact["ref"] == "aaa" + assert artifact["sha256"] == "deadbeef" + assert artifact["built_at"] =~ ~r/^2026-01-01T/ + end + + test "filters by free-text query like the LiveView", %{conn: conn} do + body = conn |> get(~p"/api/artifacts?query=27.0") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-27.0"] + end + + test "filters by kind, arch and os", %{conn: conn} do + body = conn |> get(~p"/api/artifacts?arch=arm64") |> json_response(200) + + assert Enum.map(body["artifacts"], & &1["name"]) == ["OTP-26.2"] + end + end + + describe "/docker" do + setup %{conn: conn} do + Bob.Artifacts.add_docker_tag("hexpm/erlang", "27.0-ubuntu-noble-20250101", [ + "amd64", + "arm64" + ]) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.18.0-erlang-27.0-ubuntu-noble-20250101", + ["amd64", "arm64"] + ) + + Bob.Artifacts.add_docker_tag( + "hexpm/elixir", + "1.17.3-erlang-26.2-debian-bookworm-20250113-slim", + ["amd64"] + ) + + {:ok, conn: put_req_header(conn, "accept", "application/json")} + end + + test "returns all tags as JSON", %{conn: conn} do + body = conn |> get(~p"/api/docker") |> json_response(200) + + assert body["total"] == 3 + assert body["offset"] == 0 + assert body["page_size"] == 100 + + tags = Enum.map(body["tags"], & &1["tag"]) + assert "27.0-ubuntu-noble-20250101" in tags + assert "1.18.0-erlang-27.0-ubuntu-noble-20250101" in tags + assert "1.17.3-erlang-26.2-debian-bookworm-20250113-slim" in tags + + tag = Enum.find(body["tags"], &(&1["tag"] == "27.0-ubuntu-noble-20250101")) + assert tag["repo"] == "hexpm/erlang" + assert tag["archs"] == ["amd64", "arm64"] + assert tag["built_at"] =~ ~r/^\d{4}-\d{2}-\d{2}T/ + end + + test "filters by tag prefix like the LiveView", %{conn: conn} do + body = conn |> get(~p"/api/docker?tag=1.18") |> json_response(200) + + assert body["total"] == 1 + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "filters by structured params", %{conn: conn} do + body = + conn + |> get(~p"/api/docker?repo=hexpm/elixir&os=ubuntu&erlang_version=27") + |> json_response(200) + + assert Enum.map(body["tags"], & &1["tag"]) == ["1.18.0-erlang-27.0-ubuntu-noble-20250101"] + end + + test "a tag prefix drops the structured filters, matching the form", %{conn: conn} do + body = + conn + |> get(~p"/api/docker?tag=1&erlang_version=26&os=debian") + |> json_response(200) + + assert body["total"] == 2 + end + end +end From 09a9e70af7a26757af43cb958d292d623e88c8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 16:59:35 -0600 Subject: [PATCH 3/4] Keep API auth in the endpoint, ahead of body parsing Moving the agent secret check into a router pipeline meant Plug.Parsers ran before it, so unauthenticated requests had their body parsed before being rejected. Restore BobWeb.Plugs.Secret in the endpoint and instead exempt the two public, read-only search routes from it, keeping the pre-parse rejection for all authenticated /api routes and restoring the test that asserts it. --- lib/bob_web/api_auth.ex | 23 -------------- lib/bob_web/endpoint.ex | 1 + lib/bob_web/plugs/secret.ex | 46 +++++++++++++++++++++++++++ lib/bob_web/router.ex | 10 ++---- test/bob_web/controllers/api_test.exs | 20 ++++++------ 5 files changed, 59 insertions(+), 41 deletions(-) delete mode 100644 lib/bob_web/api_auth.ex create mode 100644 lib/bob_web/plugs/secret.ex diff --git a/lib/bob_web/api_auth.ex b/lib/bob_web/api_auth.ex deleted file mode 100644 index 7770fb72..00000000 --- a/lib/bob_web/api_auth.ex +++ /dev/null @@ -1,23 +0,0 @@ -defmodule BobWeb.ApiAuth do - import Plug.Conn - - def require_api_auth(conn, _opts) do - secret = Application.get_env(:bob, :agent_secret) - - if authorized?(get_req_header(conn, "authorization"), secret) do - conn - else - conn - |> send_resp(401, "") - |> halt() - end - end - - defp authorized?([authorization], secret) - when is_binary(authorization) and is_binary(secret) and byte_size(secret) > 0 and - byte_size(authorization) == byte_size(secret) do - Plug.Crypto.secure_compare(authorization, secret) - end - - defp authorized?(_headers, _secret), do: false -end diff --git a/lib/bob_web/endpoint.ex b/lib/bob_web/endpoint.ex index fad249a2..dc781429 100644 --- a/lib/bob_web/endpoint.ex +++ b/lib/bob_web/endpoint.ex @@ -35,6 +35,7 @@ defmodule BobWeb.Endpoint do plug(Plug.RequestId) plug(Logster.Plugs.Logger, excludes: [:params]) + plug(BobWeb.Plugs.Secret, api_only: true) plug(Plug.Parsers, parsers: [:urlencoded, :json, Bob.Plug.Parser], diff --git a/lib/bob_web/plugs/secret.ex b/lib/bob_web/plugs/secret.ex new file mode 100644 index 00000000..50d5daf7 --- /dev/null +++ b/lib/bob_web/plugs/secret.ex @@ -0,0 +1,46 @@ +defmodule BobWeb.Plugs.Secret do + import Plug.Conn + + # The public, read-only search API needs no secret; every other /api route + # is agent-only. The check stays in the endpoint, ahead of Plug.Parsers, so + # unauthorized requests are rejected before their body is parsed. + @public_api [{"GET", ["api", "artifacts"]}, {"GET", ["api", "docker"]}] + + def init(opts), do: opts + + def call(%{path_info: ["api" | _] = path_info} = conn, _opts) do + if {conn.method, path_info} in @public_api do + conn + else + authenticate(conn) + end + end + + def call(conn, opts) when is_list(opts) do + if Keyword.get(opts, :api_only, false) do + conn + else + authenticate(conn) + end + end + + defp authenticate(conn) do + secret = Application.get_env(:bob, :agent_secret) + + if authorized?(get_req_header(conn, "authorization"), secret) do + conn + else + conn + |> send_resp(401, "") + |> halt() + end + end + + defp authorized?([authorization], secret) + when is_binary(authorization) and is_binary(secret) and byte_size(secret) > 0 and + byte_size(authorization) == byte_size(secret) do + Plug.Crypto.secure_compare(authorization, secret) + end + + defp authorized?(_headers, _secret), do: false +end diff --git a/lib/bob_web/router.ex b/lib/bob_web/router.ex index ca3bb160..28db30e2 100644 --- a/lib/bob_web/router.ex +++ b/lib/bob_web/router.ex @@ -2,7 +2,6 @@ defmodule BobWeb.Router do use BobWeb, :router import BobWeb.UserAuth - import BobWeb.ApiAuth pipeline :browser do plug(:accepts, ["html"]) @@ -14,17 +13,12 @@ defmodule BobWeb.Router do plug(:fetch_current_user) end - pipeline :api do - plug(:require_api_auth) - end - pipeline :public_api do plug(:accepts, ["json"]) end + # Authenticated in the endpoint by BobWeb.Plugs.Secret, before body parsing. scope "/api", BobWeb do - pipe_through(:api) - post("/queue/start", QueueController, :start) post("/queue/success", QueueController, :success) post("/queue/failure", QueueController, :failure) @@ -34,7 +28,7 @@ defmodule BobWeb.Router do post("/docker/add", ArtifactController, :add_docker) end - # Public API routes + # Public API routes, exempted from agent auth in BobWeb.Plugs.Secret. scope "/api", BobWeb do pipe_through(:public_api) diff --git a/test/bob_web/controllers/api_test.exs b/test/bob_web/controllers/api_test.exs index b6d95a0e..15261013 100644 --- a/test/bob_web/controllers/api_test.exs +++ b/test/bob_web/controllers/api_test.exs @@ -53,16 +53,16 @@ defmodule BobWeb.ApiTest do assert Repo.all(Artifact) == [] end - # test "POST /api/artifacts/add rejects an invalid secret before parsing the body", %{conn: conn} do - # conn = - # conn - # |> put_req_header("content-type", "application/json") - # |> put_req_header("authorization", "public") - # |> post(~p"/api/artifacts/add", "{") - - # assert conn.status == 401 - # assert Repo.all(Artifact) == [] - # end + test "POST /api/artifacts/add rejects an invalid secret before parsing the body", %{conn: conn} do + conn = + conn + |> put_req_header("content-type", "application/json") + |> put_req_header("authorization", "public") + |> post(~p"/api/artifacts/add", "{") + + assert conn.status == 401 + assert Repo.all(Artifact) == [] + end test "POST /api/docker/add upserts a docker tag and returns 204", %{conn: conn} do body = From 741598c5114d92c601968f121d99596534f3e27c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Wed, 1 Jul 2026 16:59:35 -0600 Subject: [PATCH 4/4] Rename JSON search test module to BobWeb.PublicApiTest The module kept its name from the content-negotiation PR; it now covers both public endpoints and lives in public_api_test.exs. --- test/bob_web/public_api_test.exs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/bob_web/public_api_test.exs b/test/bob_web/public_api_test.exs index ef7d2e00..d6f59a55 100644 --- a/test/bob_web/public_api_test.exs +++ b/test/bob_web/public_api_test.exs @@ -1,4 +1,4 @@ -defmodule BobWeb.ArtifactsJsonTest do +defmodule BobWeb.PublicApiTest do use BobWeb.ConnCase describe "/artifacts" do