File size: 4,944 Bytes
8da2481 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 | 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
# owner ID can be null (aka segment is dangling) when the original owner is deassociated from the site
# the segment is dangling until another user edits it: the editor becomes the new owner
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
|