Fix MRF policies to also work with Update
[akkoma] / lib / pleroma / filter.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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