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