diff --git a/lib/paginator.ex b/lib/paginator.ex index 7bec24b..35be525 100644 --- a/lib/paginator.ex +++ b/lib/paginator.ex @@ -32,7 +32,7 @@ defmodule Paginator do import Ecto.Query - alias Paginator.{Config, Cursor, Ecto.Query, Page, Page.Metadata} + alias Paginator.{Config, Ecto.Query, Page, Page.Metadata} defmacro __using__(opts) do quote do @@ -42,8 +42,19 @@ defmodule Paginator do opts = Keyword.merge(@defaults, opts) config = Config.new(opts) - unless config.cursor_fields, - do: raise("expected `:cursor_fields` to be set in call to paginate/3") + case config do + %{cursor_fields: nil} -> + raise("expected `:cursor_fields` to be set in call to paginate/3") + + %{after_values: {:error, err}} -> + raise("error decoding `:after` cursor (#{err})") + + %{before_values: {:error, err}} -> + raise("error decoding `:before` cursor (#{err})") + + _ -> + nil + end Paginator.paginate(queryable, config, __MODULE__, repo_opts) end @@ -153,10 +164,14 @@ defmodule Paginator do end end - defp fetch_cursor_value(schema, %Config{cursor_fields: cursor_fields}) do + defp fetch_cursor_value(schema, %Config{ + cursor_fields: cursor_fields, + cursor_module: cursor_module, + cursor_module_opts: cursor_module_opts + }) do cursor_fields |> Enum.map(fn field -> Map.get(schema, field) end) - |> Cursor.encode() + |> cursor_module.encode!(cursor_module_opts) end defp first_page?(sorted_entries, %Config{limit: limit}) do diff --git a/lib/paginator/config.ex b/lib/paginator/config.ex index e710f17..be7baef 100644 --- a/lib/paginator/config.ex +++ b/lib/paginator/config.ex @@ -1,8 +1,6 @@ defmodule Paginator.Config do @moduledoc false - alias Paginator.Cursor - @type t :: %__MODULE__{} defstruct [ @@ -11,6 +9,8 @@ defmodule Paginator.Config do :before, :before_values, :cursor_fields, + :cursor_module, + :cursor_module_opts, :include_total_count, :limit, :maximum_limit, @@ -22,14 +22,17 @@ defmodule Paginator.Config do @minimum_limit 1 @maximum_limit 500 @default_total_count_limit 10_000 + @default_cursor_module Paginator.Cursors.UnencryptedCursor def new(opts \\ []) do %__MODULE__{ after: opts[:after], - after_values: Cursor.decode(opts[:after]), + after_values: cursor_module(opts).decode(opts[:after], opts[:cursor_module_opts]), before: opts[:before], - before_values: Cursor.decode(opts[:before]), + before_values: cursor_module(opts).decode(opts[:before], opts[:cursor_module_opts]), cursor_fields: opts[:cursor_fields], + cursor_module: cursor_module(opts), + cursor_module_opts: opts[:cursor_module_opts] || [], include_total_count: opts[:include_total_count] || false, limit: limit(opts), sort_direction: opts[:sort_direction] || :asc, @@ -37,6 +40,10 @@ defmodule Paginator.Config do } end + defp cursor_module(opts) do + opts[:cursor_module] || @default_cursor_module + end + defp limit(opts) do max(opts[:limit] || @default_limit, @minimum_limit) |> min(opts[:maximum_limit] || @maximum_limit) diff --git a/lib/paginator/cursor.ex b/lib/paginator/cursor.ex index 630cdc6..bd8ea9a 100644 --- a/lib/paginator/cursor.ex +++ b/lib/paginator/cursor.ex @@ -1,21 +1,7 @@ defmodule Paginator.Cursor do - @moduledoc false - - def decode(nil), do: nil - - def decode(encoded_cursor) do - encoded_cursor - |> Base.url_decode64!() - |> :erlang.binary_to_term() - end - - def encode(values) when is_list(values) do - values - |> :erlang.term_to_binary() - |> Base.url_encode64() - end - - def encode(value) do - encode([value]) - end + @callback decode(String.t(), opts :: list()) :: {:ok, term} | {:error, term} + @callback decode!(String.t(), opts :: list()) :: term + @callback encode(cursor_fields :: term, opts :: list()) :: + {:ok, encoded :: String.t()} | {:error, term} + @callback encode!(cursor_fields :: term, opts :: list()) :: encoded :: String.t() end diff --git a/lib/paginator/cursors/unencrypted_cursor.ex b/lib/paginator/cursors/unencrypted_cursor.ex new file mode 100644 index 0000000..b88c402 --- /dev/null +++ b/lib/paginator/cursors/unencrypted_cursor.ex @@ -0,0 +1,39 @@ +defmodule Paginator.Cursors.UnencryptedCursor do + @behaviour Paginator.Cursor + @moduledoc false + + def decode(cursor, opts \\ []) + def decode(nil, _opts), do: nil + + def decode(encoded_cursor, _opts) do + {:ok, + encoded_cursor + |> Base.url_decode64!() + |> :erlang.binary_to_term()} + end + + def decode!(encoded_cursor, opts \\ []) do + with {:ok, decoded} <- decode(encoded_cursor, opts) do + decoded + end + end + + def encode(values, opts \\ []) + + def encode(values, _opts) when is_list(values) do + {:ok, + values + |> :erlang.term_to_binary() + |> Base.url_encode64()} + end + + def encode(value, opts) do + encode([value], opts) + end + + def encode!(value, opts \\ []) do + with {:ok, encoded} <- encode(value, opts) do + encoded + end + end +end diff --git a/lib/paginator/ecto/query.ex b/lib/paginator/ecto/query.ex index bb0f79f..93642b2 100644 --- a/lib/paginator/ecto/query.ex +++ b/lib/paginator/ecto/query.ex @@ -61,7 +61,7 @@ defmodule Paginator.Ecto.Query do end defp maybe_where(query, %Config{ - after_values: after_values, + after_values: {:ok, after_values}, before: nil, cursor_fields: cursor_fields, sort_direction: :asc @@ -72,7 +72,7 @@ defmodule Paginator.Ecto.Query do defp maybe_where(query, %Config{ after_values: nil, - before_values: before_values, + before_values: {:ok, before_values}, cursor_fields: cursor_fields, sort_direction: :asc }) do @@ -82,8 +82,8 @@ defmodule Paginator.Ecto.Query do end defp maybe_where(query, %Config{ - after_values: after_values, - before_values: before_values, + after_values: {:ok, after_values}, + before_values: {:ok, before_values}, cursor_fields: cursor_fields, sort_direction: :asc }) do @@ -101,7 +101,7 @@ defmodule Paginator.Ecto.Query do end defp maybe_where(query, %Config{ - after_values: after_values, + after_values: {:ok, after_values}, before: nil, cursor_fields: cursor_fields, sort_direction: :desc @@ -112,7 +112,7 @@ defmodule Paginator.Ecto.Query do defp maybe_where(query, %Config{ after: nil, - before_values: before_values, + before_values: {:ok, before_values}, cursor_fields: cursor_fields, sort_direction: :desc }) do @@ -122,8 +122,8 @@ defmodule Paginator.Ecto.Query do end defp maybe_where(query, %Config{ - after_values: after_values, - before_values: before_values, + after_values: {:ok, after_values}, + before_values: {:ok, before_values}, cursor_fields: cursor_fields, sort_direction: :desc }) do diff --git a/mix.exs b/mix.exs index 3c0cfa3..4a8efb3 100644 --- a/mix.exs +++ b/mix.exs @@ -44,7 +44,8 @@ defmodule Paginator.Mixfile do {:ex_doc, "~> 0.18", only: :dev, runtime: false}, {:ex_machina, "~> 2.1", only: :test}, {:inch_ex, "~> 0.5", only: [:dev, :test]}, - {:postgrex, "~> 0.13", optional: true} + {:postgrex, "~> 0.13", optional: true}, + {:plug, "~> 1.4", optional: true} ] end diff --git a/mix.lock b/mix.lock index 8a577dd..0db05cf 100644 --- a/mix.lock +++ b/mix.lock @@ -12,7 +12,9 @@ "idna": {:hex, :idna, "5.1.0", "d72b4effeb324ad5da3cab1767cb16b17939004e789d8c0ad5b70f3cea20c89a", [:rebar3], [{:unicode_util_compat, "0.3.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm"}, "inch_ex": {:hex, :inch_ex, "0.5.6", "418357418a553baa6d04eccd1b44171936817db61f4c0840112b420b8e378e67", [:mix], [{:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm"}, + "mime": {:hex, :mime, "1.2.0", "78adaa84832b3680de06f88f0997e3ead3b451a440d183d688085be2d709b534", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.0.2", "993f9b0e084083405ed8252b99460c4f0563e41729ab42d9074fd5e52439be88", [:rebar3], [], "hexpm"}, + "plug": {:hex, :plug, "1.4.5", "7b13869283fff6b8b21b84b8735326cc012c5eef8607095dc6ee24bd0a273d8e", [], [{:cowboy, "~> 1.0.1 or ~> 1.1", [hex: :cowboy, repo: "hexpm", optional: true]}, {:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}], "hexpm"}, "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"}, "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.13.4", "f58e319c5451bfda86ba6a45ce6dca311193d0a9861323d0d16e8d02e25adc41", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"}, diff --git a/test/paginator/config_test.exs b/test/paginator/config_test.exs index 5e0f7e6..863cd5e 100644 --- a/test/paginator/config_test.exs +++ b/test/paginator/config_test.exs @@ -1,7 +1,9 @@ defmodule Paginator.ConfigTest do use ExUnit.Case, async: true - alias Paginator.{Config, Cursor} + alias Paginator.Config + alias Paginator.Cursors.UnencryptedCursor, as: Cursor + alias Paginator.Cursors.EncryptedCursor describe "Config.new/2" do test "creates a new config" do @@ -12,6 +14,13 @@ defmodule Paginator.ConfigTest do assert config.limit == 10 assert config.cursor_fields == [:id] end + + test "creates a new config with custom cursor_module" do + config = Config.new(cursor_fields: [:id], cursor_module: EncryptedCursor, cursor_module_opts: [encryption_key: "123", signing_key: "321"]) + + assert config.cursor_module == EncryptedCursor + assert config.cursor_module_opts == [encryption_key: "123", signing_key: "321"] + end end describe "Config.new/2 applies min/max limit" do @@ -44,7 +53,7 @@ defmodule Paginator.ConfigTest do config = Config.new(limit: 10, cursor_fields: [:id], before: simple_before()) assert config.after_values == nil - assert config.before_values == ["pay_789"] + assert config.before_values == {:ok, ["pay_789"]} assert config.limit == 10 assert config.cursor_fields == [:id] end @@ -52,7 +61,7 @@ defmodule Paginator.ConfigTest do test "simple after" do config = Config.new(limit: 10, cursor_fields: [:id], after: simple_after()) - assert config.after_values == ["pay_123"] + assert config.after_values == {:ok, ["pay_123"]} assert config.before_values == nil assert config.limit == 10 assert config.cursor_fields == [:id] @@ -62,7 +71,7 @@ defmodule Paginator.ConfigTest do config = Config.new(limit: 10, cursor_fields: [:created_at, :id], before: complex_before()) assert config.after_values == nil - assert config.before_values == ["2036-02-09T20:00:00.000Z", "pay_789"] + assert config.before_values == {:ok, ["2036-02-09T20:00:00.000Z", "pay_789"]} assert config.limit == 10 assert config.cursor_fields == [:created_at, :id] end @@ -70,15 +79,15 @@ defmodule Paginator.ConfigTest do test "complex after" do config = Config.new(limit: 10, cursor_fields: [:created_at, :id], after: complex_after()) - assert config.after_values == ["2036-02-09T20:00:00.000Z", "pay_123"] + assert config.after_values == {:ok, ["2036-02-09T20:00:00.000Z", "pay_123"]} assert config.before_values == nil assert config.limit == 10 assert config.cursor_fields == [:created_at, :id] end end - def simple_after, do: Cursor.encode("pay_123") - def simple_before, do: Cursor.encode("pay_789") - def complex_after, do: Cursor.encode(["2036-02-09T20:00:00.000Z", "pay_123"]) - def complex_before, do: Cursor.encode(["2036-02-09T20:00:00.000Z", "pay_789"]) + def simple_after, do: Cursor.encode!("pay_123") + def simple_before, do: Cursor.encode!("pay_789") + def complex_after, do: Cursor.encode!(["2036-02-09T20:00:00.000Z", "pay_123"]) + def complex_before, do: Cursor.encode!(["2036-02-09T20:00:00.000Z", "pay_789"]) end diff --git a/test/paginator_test.exs b/test/paginator_test.exs index 2006edd..6f01fe0 100644 --- a/test/paginator_test.exs +++ b/test/paginator_test.exs @@ -3,7 +3,9 @@ defmodule PaginatorTest do alias Calendar.DateTime, as: DT - alias Paginator.Cursor + alias Paginator.Cursors.UnencryptedCursor, as: Cursor + alias Paginator.Cursors.EncryptedCursor + alias Plug.Crypto.KeyGenerator setup :create_customers_and_payments @@ -606,6 +608,84 @@ defmodule PaginatorTest do end end + describe "with custom encrypted cursor_module" do + setup :generate_keys + + defp generate_keys(_context) do + secret_key_base = "PwGqIq85ScEflB9/3SpHjEHb44uTMFVGP+vleZTSr63NvPkWwHIAtGzgjpLvjDbA" + salt = "usersalt" + + {:ok, + key: + KeyGenerator.generate( + secret_key_base, + salt, + iterations: 1000, + length: 32, + digest: :sha256 + )} + end + + test "encrypted cursor", %{ + customers: {c1, _c2, _c3}, + payments: {_p1, _p2, _p3, _p4, p5, p6, p7, p8, _p9, _p10, _p11, _p12}, + key: key + } do + %Page{entries: entries, metadata: %{after: after_cursor}} = + customer_payments_by_amount(c1) + |> Repo.paginate( + cursor_fields: [:amount, :charged_at, :id], + sort_direction: :asc, + limit: 1, + cursor_module: EncryptedCursor, + cursor_module_opts: [ + encryption_key: key, + signing_key: key + ] + ) + + assert to_ids(entries) == to_ids([p6]) + + %Page{entries: entries} = + customer_payments_by_amount(c1) + |> Repo.paginate( + after: after_cursor, + cursor_fields: [:amount, :charged_at, :id], + sort_direction: :asc, + limit: 3, + cursor_module: EncryptedCursor, + cursor_module_opts: [ + encryption_key: key, + signing_key: key + ] + ) + + assert to_ids(entries) == to_ids([p5, p7, p8]) + end + + test "decoding cursor raises error", %{ + customers: {c1, _c2, _c3}, + key: key + } do + assert_raise RuntimeError, + "error decoding `:after` cursor (Could not decode encrypted cursor)", + fn -> + customer_payments_by_amount(c1) + |> Repo.paginate( + cursor_fields: [:amount, :charged_at, :id], + after: "badcursor", + sort_direction: :asc, + limit: 1, + cursor_module: EncryptedCursor, + cursor_module_opts: [ + encryption_key: key, + signing_key: key + ] + ) + end + end + end + defp to_ids(entries), do: Enum.map(entries, & &1.id) defp create_customers_and_payments(_context) do @@ -666,7 +746,7 @@ defmodule PaginatorTest do end defp encode_cursor(value) do - Cursor.encode(value) + Cursor.encode!(value) end defp days_ago(days) do diff --git a/test/support/encrypted_cursor.ex b/test/support/encrypted_cursor.ex new file mode 100644 index 0000000..438acbd --- /dev/null +++ b/test/support/encrypted_cursor.ex @@ -0,0 +1,52 @@ +defmodule Paginator.Cursors.EncryptedCursor do + @behaviour Paginator.Cursor + @moduledoc """ + Example encrypted cursor using plug MessageEncryptor + Modeled after Plug.Crypto.COOKIE + """ + + alias Plug.Crypto.MessageEncryptor + + def decode(nil, _opts), do: nil + + def decode(encrypted_cursor, opts) do + with {:ok, binary} <- + MessageEncryptor.decrypt(encrypted_cursor, opts[:encryption_key], opts[:signing_key]), + term <- Plug.Crypto.safe_binary_to_term(binary) do + {:ok, term} + else + _err -> {:error, "Could not decode encrypted cursor"} + end + end + + def decode!(encrypted_cursor, opts) do + with {:ok, decoded} <- decode(encrypted_cursor, opts) do + decoded + end + end + + def encode(values, opts) when is_list(values) do + encrypt_cursor(values, opts) + end + + def encode(value, opts) do + encode([value], opts) + end + + def encode!(value, opts) do + with {:ok, encoded} <- encode(value, opts) do + encoded + end + end + + defp encrypt_cursor(term, opts) do + with encrypted_cursor <- + MessageEncryptor.encrypt( + :erlang.term_to_binary(term), + opts[:encryption_key], + opts[:signing_key] + ) do + {:ok, encrypted_cursor} + end + end +end