| defmodule Plausible.Segments.Segment do |
| @moduledoc """ |
| Schema for segments. Segments are saved filter combinations. |
| """ |
| use Plausible |
| use Ecto.Schema |
| import Ecto.Changeset |
| alias Plausible.Stats.{ApiQueryParser, QueryBuilder, ParsedQueryParams, QueryError} |
|
|
| @segment_types [:personal, :site] |
|
|
| @type t() :: %__MODULE__{} |
|
|
| schema "segments" do |
| field :name, :string |
| field :type, Ecto.Enum, values: @segment_types |
| field :segment_data, :map |
|
|
| |
| |
| belongs_to :owner, Plausible.Auth.User, foreign_key: :owner_id |
| belongs_to :site, Plausible.Site |
|
|
| timestamps() |
| end |
|
|
| def changeset(segment, attrs) do |
| segment |
| |> cast(attrs, [ |
| :name, |
| :segment_data, |
| :type, |
| :owner_id |
| ]) |
| |> validate_required([:name, :segment_data, :site_id, :type, :owner_id]) |
| |> validate_length(:name, count: :bytes, min: 1, max: 255) |
| |> foreign_key_constraint(:site_id) |
| |> foreign_key_constraint(:owner_id) |
| |> validate_only_known_properties_present() |
| |> validate_segment_data_filters() |
| |> validate_segment_data_labels() |
| |> validate_json_byte_length(:segment_data, max: 5 * 1024) |
| end |
|
|
| defp validate_only_known_properties_present(%Ecto.Changeset{} = changeset) do |
| case get_field(changeset, :segment_data) do |
| segment_data when is_map(segment_data) -> |
| if Enum.any?(Map.keys(segment_data) -- ["filters", "labels"]) do |
| add_error( |
| changeset, |
| :segment_data, |
| "must not contain any other property except \"filters\" and \"labels\"" |
| ) |
| else |
| changeset |
| end |
|
|
| _ -> |
| changeset |
| end |
| end |
|
|
| defp validate_segment_data_filters(%Ecto.Changeset{} = changeset) do |
| case get_field(changeset, :segment_data) do |
| %{"filters" => filters} when is_list(filters) and length(filters) > 0 -> |
| changeset |
|
|
| _ -> |
| add_error( |
| changeset, |
| :segment_data, |
| "property \"filters\" must be an array with at least one member" |
| ) |
| end |
| end |
|
|
| defp validate_segment_data_labels(%Ecto.Changeset{} = changeset) do |
| case get_field(changeset, :segment_data) do |
| %{"labels" => labels} when not is_map(labels) -> |
| add_error(changeset, :segment_data, "property \"labels\" must be map or nil") |
|
|
| _ -> |
| changeset |
| end |
| end |
|
|
| defp validate_json_byte_length(%Ecto.Changeset{} = changeset, field_key, opts) do |
| field = get_field(changeset, field_key) |
| max = Keyword.get(opts, :max, 0) |
|
|
| if :erlang.byte_size(Jason.encode!(field)) > max do |
| add_error(changeset, field_key, "should be at most %{count} byte(s)", count: max) |
| else |
| changeset |
| end |
| end |
|
|
| def validate_segment_data_if_exists( |
| %Plausible.Site{} = _site, |
| nil = _segment_data, |
| _restricted_depth? |
| ), |
| do: :ok |
|
|
| def validate_segment_data_if_exists(%Plausible.Site{} = site, segment_data, restricted_depth?) do |
| validate_segment_data(site, segment_data, restricted_depth?) |
| end |
|
|
| @spec validate_segment_data(Plausible.Site.t(), map(), boolean()) :: |
| :ok | {:error, {:invalid_filters, String.t()}} |
| @doc """ |
| Checks whether the filters in segment_data are valid by attempting to build |
| a `%Query{}` struct with them. The `metrics` and `input_date_range` values |
| do not matter as long as they don't affect filters being the only field |
| affecting query validity. |
| """ |
| def validate_segment_data(%Plausible.Site{} = site, %{"filters" => filters}, restricted_depth?) do |
| with {:ok, parsed_filters} <- ApiQueryParser.parse_filters(filters), |
| {:ok, _} <- |
| QueryBuilder.build(site, %ParsedQueryParams{ |
| metrics: [:visitors], |
| input_date_range: {:last_n_days, 7}, |
| filters: parsed_filters |
| }), |
| :ok <- maybe_validate_filters_depth(parsed_filters, restricted_depth?) do |
| :ok |
| else |
| {:error, %QueryError{code: :invalid_filters, message: message}} -> |
| {:error, {:invalid_filters, message}} |
|
|
| {:error, :deep_filters_not_supported} -> |
| {:error, {:invalid_filters, "Invalid filters. Deep filters are not supported."}} |
| end |
| end |
|
|
| defp maybe_validate_filters_depth(filters, restricted_depth?) |
|
|
| defp maybe_validate_filters_depth(_filters, false), do: :ok |
|
|
| defp maybe_validate_filters_depth(filters, true) do |
| if Enum.all?(filters, &dashboard_compatible_filter?/1) do |
| :ok |
| else |
| {:error, :deep_filters_not_supported} |
| end |
| end |
|
|
| defp dashboard_compatible_filter?(filter) do |
| case filter do |
| [operation, dimension, _clauses] when is_atom(operation) and is_binary(dimension) -> true |
| [:has_not_done, _] -> true |
| _ -> false |
| end |
| end |
| end |
|
|