db88bc02125984e3c03d02b3a427e4023dfc3b94
[anni] / lib / pleroma / filter.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Filter do
6   use Ecto.Schema
7
8   import Ecto.Changeset
9   import Ecto.Query
10
11   alias Pleroma.Repo
12   alias Pleroma.User
13
14   @type t() :: %__MODULE__{}
15   @type format() :: :postgres | :re
16
17   schema "filters" do
18     belongs_to(:user, User, type: FlakeId.Ecto.CompatType)
19     field(:filter_id, :integer)
20     field(:hide, :boolean, default: false)
21     field(:whole_word, :boolean, default: true)
22     field(:phrase, :string)
23     field(:context, {:array, :string})
24     field(:expires_at, :naive_datetime)
25
26     timestamps()
27   end
28
29   @spec get(integer() | String.t(), User.t()) :: t() | nil
30   def get(id, %{id: user_id} = _user) do
31     query =
32       from(
33         f in __MODULE__,
34         where: f.filter_id == ^id,
35         where: f.user_id == ^user_id
36       )
37
38     Repo.one(query)
39   end
40
41   @spec get_active(Ecto.Query.t() | module()) :: Ecto.Query.t()
42   def get_active(query) do
43     from(f in query, where: is_nil(f.expires_at) or f.expires_at > ^NaiveDateTime.utc_now())
44   end
45
46   @spec get_irreversible(Ecto.Query.t()) :: Ecto.Query.t()
47   def get_irreversible(query) do
48     from(f in query, where: f.hide)
49   end
50
51   @spec get_filters(Ecto.Query.t() | module(), User.t()) :: [t()]
52   def get_filters(query \\ __MODULE__, %User{id: user_id}) do
53     query =
54       from(
55         f in query,
56         where: f.user_id == ^user_id,
57         order_by: [desc: :id]
58       )
59
60     Repo.all(query)
61   end
62
63   @spec create(map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
64   def create(attrs \\ %{}) do
65     Repo.transaction(fn -> create_with_expiration(attrs) end)
66   end
67
68   defp create_with_expiration(attrs) do
69     with {:ok, filter} <- do_create(attrs),
70          {:ok, _} <- maybe_add_expiration_job(filter) do
71       filter
72     else
73       {:error, error} -> Repo.rollback(error)
74     end
75   end
76
77   defp do_create(attrs) do
78     %__MODULE__{}
79     |> cast(attrs, [:phrase, :context, :hide, :expires_at, :whole_word, :user_id, :filter_id])
80     |> maybe_add_filter_id()
81     |> validate_required([:phrase, :context, :user_id, :filter_id])
82     |> maybe_add_expires_at(attrs)
83     |> Repo.insert()
84   end
85
86   defp maybe_add_filter_id(%{changes: %{filter_id: _}} = changeset), do: changeset
87
88   defp maybe_add_filter_id(%{changes: %{user_id: user_id}} = changeset) do
89     # If filter_id wasn't given, use the max filter_id for this user plus 1.
90     # XXX This could result in a race condition if a user tries to add two
91     # different filters for their account from two different clients at the
92     # same time, but that should be unlikely.
93
94     max_id_query =
95       from(
96         f in __MODULE__,
97         where: f.user_id == ^user_id,
98         select: max(f.filter_id)
99       )
100
101     filter_id =
102       case Repo.one(max_id_query) do
103         # Start allocating from 1
104         nil ->
105           1
106
107         max_id ->
108           max_id + 1
109       end
110
111     change(changeset, filter_id: filter_id)
112   end
113
114   # don't override expires_at, if passed expires_at and expires_in
115   defp maybe_add_expires_at(%{changes: %{expires_at: %NaiveDateTime{} = _}} = changeset, _) do
116     changeset
117   end
118
119   defp maybe_add_expires_at(changeset, %{expires_in: expires_in})
120        when is_integer(expires_in) and expires_in > 0 do
121     expires_at =
122       NaiveDateTime.utc_now()
123       |> NaiveDateTime.add(expires_in)
124       |> NaiveDateTime.truncate(:second)
125
126     change(changeset, expires_at: expires_at)
127   end
128
129   defp maybe_add_expires_at(changeset, %{expires_in: nil}) do
130     change(changeset, expires_at: nil)
131   end
132
133   defp maybe_add_expires_at(changeset, _), do: changeset
134
135   defp maybe_add_expiration_job(%{expires_at: %NaiveDateTime{} = expires_at} = filter) do
136     Pleroma.Workers.PurgeExpiredFilter.enqueue(%{
137       filter_id: filter.id,
138       expires_at: DateTime.from_naive!(expires_at, "Etc/UTC")
139     })
140   end
141
142   defp maybe_add_expiration_job(_), do: {:ok, nil}
143
144   @spec delete(t()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
145   def delete(%__MODULE__{} = filter) do
146     Repo.transaction(fn -> delete_with_expiration(filter) end)
147   end
148
149   defp delete_with_expiration(filter) do
150     with {:ok, _} <- maybe_delete_old_expiration_job(filter, nil),
151          {:ok, filter} <- Repo.delete(filter) do
152       filter
153     else
154       {:error, error} -> Repo.rollback(error)
155     end
156   end
157
158   @spec update(t(), map()) :: {:ok, t()} | {:error, Ecto.Changeset.t()}
159   def update(%__MODULE__{} = filter, params) do
160     Repo.transaction(fn -> update_with_expiration(filter, params) end)
161   end
162
163   defp update_with_expiration(filter, params) do
164     with {:ok, updated} <- do_update(filter, params),
165          {:ok, _} <- maybe_delete_old_expiration_job(filter, updated),
166          {:ok, _} <-
167            maybe_add_expiration_job(updated) do
168       updated
169     else
170       {:error, error} -> Repo.rollback(error)
171     end
172   end
173
174   defp do_update(filter, params) do
175     filter
176     |> cast(params, [:phrase, :context, :hide, :expires_at, :whole_word])
177     |> validate_required([:phrase, :context])
178     |> maybe_add_expires_at(params)
179     |> Repo.update()
180   end
181
182   defp maybe_delete_old_expiration_job(%{expires_at: nil}, _), do: {:ok, nil}
183
184   defp maybe_delete_old_expiration_job(%{expires_at: expires_at}, %{expires_at: expires_at}) do
185     {:ok, nil}
186   end
187
188   defp maybe_delete_old_expiration_job(%{id: id}, _) do
189     with %Oban.Job{} = job <- Pleroma.Workers.PurgeExpiredFilter.get_expiration(id) do
190       Repo.delete(job)
191     else
192       nil -> {:ok, nil}
193     end
194   end
195
196   @spec compose_regex(User.t() | [t()], format()) :: String.t() | Regex.t() | nil
197   def compose_regex(user_or_filters, format \\ :postgres)
198
199   def compose_regex(%User{} = user, format) do
200     __MODULE__
201     |> get_active()
202     |> get_irreversible()
203     |> get_filters(user)
204     |> compose_regex(format)
205   end
206
207   def compose_regex([_ | _] = filters, format) do
208     phrases =
209       filters
210       |> Enum.map(& &1.phrase)
211       |> Enum.join("|")
212
213     case format do
214       :postgres ->
215         "\\y(#{phrases})\\y"
216
217       :re ->
218         ~r/\b#{phrases}\b/i
219
220       _ ->
221         nil
222     end
223   end
224
225   def compose_regex(_, _), do: nil
226 end