diff --git a/CHANGELOG.md b/CHANGELOG.md index 488e03507b2d..cd33a4c4a917 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ All notable changes to this project will be documented in this file. - Fixed main graph being clipped when the browser's root font size is smaller than the default 16px - Fixed issue with users with billing role not being able to create personal segments - Fixed period arrow keys hijacking custom-range calendar +- Return a 400 instead of a 500 from the Stats API breakdown endpoint when `page` is not a positive integer ## v3.2.0 - 2026-01-16 diff --git a/lib/plausible_web/controllers/api/external_stats_controller.ex b/lib/plausible_web/controllers/api/external_stats_controller.ex index 50cebddd70dc..63d7831bd332 100644 --- a/lib/plausible_web/controllers/api/external_stats_controller.ex +++ b/lib/plausible_web/controllers/api/external_stats_controller.ex @@ -40,9 +40,8 @@ defmodule PlausibleWeb.Api.ExternalStatsController do :ok <- validate_filters(site, query.filters), {:ok, metrics} <- parse_and_validate_metrics(params, query), {:ok, limit} <- validate_or_default_limit(params), + {:ok, page} <- validate_or_default_page(params), :ok <- ensure_custom_props_access(site, query) do - page = String.to_integer(Map.get(params, "page", "1")) - %{results: results, meta: meta} = Legacy.Breakdown.breakdown(site, query, metrics, {limit, page}) @@ -88,6 +87,18 @@ defmodule PlausibleWeb.Api.ExternalStatsController do @default_breakdown_limit 100 defp validate_or_default_limit(_), do: {:ok, @default_breakdown_limit} + defp validate_or_default_page(%{"page" => page}) do + case Integer.parse(page) do + {page, ""} when page > 0 -> + {:ok, page} + + _ -> + {:error, "Please provide page as a positive number."} + end + end + + defp validate_or_default_page(_), do: {:ok, 1} + defp parse_and_validate_metrics(params, query) do metrics = Map.get(params, "metrics", "visitors") diff --git a/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs index b13b94870e44..f37886428b5f 100644 --- a/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs +++ b/test/plausible_web/controllers/api/external_stats_controller/breakdown_test.exs @@ -2492,6 +2492,30 @@ defmodule PlausibleWeb.Api.ExternalStatsController.BreakdownTest do assert json_response(conn, 400) == %{"error" => @invalid_limit_message} end + @invalid_page_message "Please provide page as a positive number." + + test "returns error with non-integer page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "page" => "foo" + }) + + assert json_response(conn, 400) == %{"error" => @invalid_page_message} + end + + test "returns error with non-positive page", %{conn: conn, site: site} do + conn = + get(conn, "/api/v1/stats/breakdown", %{ + "site_id" => site.domain, + "property" => "event:page", + "page" => 0 + }) + + assert json_response(conn, 400) == %{"error" => @invalid_page_message} + end + test "can paginate results", %{conn: conn, site: site} do populate_stats(site, [ build(:pageview, pathname: "/a", timestamp: ~N[2021-01-01 00:00:00]),