[#3213] Hashtag-filtering functions in ActivityPub. Mix task for migrating hashtags...
[akkoma] / lib / pleroma / object.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Object do
6 use Ecto.Schema
7
8 import Ecto.Query
9 import Ecto.Changeset
10
11 alias Pleroma.Activity
12 alias Pleroma.Config
13 alias Pleroma.Hashtag
14 alias Pleroma.Object
15 alias Pleroma.Object.Fetcher
16 alias Pleroma.ObjectTombstone
17 alias Pleroma.Repo
18 alias Pleroma.User
19 alias Pleroma.Workers.AttachmentsCleanupWorker
20
21 require Logger
22
23 @type t() :: %__MODULE__{}
24
25 @derive {Jason.Encoder, only: [:data]}
26
27 schema "objects" do
28 field(:data, :map)
29
30 many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete)
31
32 timestamps()
33 end
34
35 def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) do
36 object_position = Map.get(query.aliases, :object, 0)
37
38 join(query, join_type, [{object, object_position}], a in Activity,
39 on:
40 fragment(
41 "COALESCE(?->'object'->>'id', ?->>'object') = (? ->> 'id') AND (?->>'type' = ?) ",
42 a.data,
43 a.data,
44 object.data,
45 a.data,
46 ^activity_type
47 ),
48 as: :object_activity
49 )
50 end
51
52 def create(data) do
53 %Object{}
54 |> Object.change(%{data: data})
55 |> Repo.insert()
56 end
57
58 def change(struct, params \\ %{}) do
59 struct
60 |> cast(params, [:data])
61 |> validate_required([:data])
62 |> unique_constraint(:ap_id, name: :objects_unique_apid_index)
63 |> maybe_handle_hashtags_change(struct)
64 end
65
66 defp maybe_handle_hashtags_change(changeset, struct) do
67 with data_hashtags_change = get_change(changeset, :data),
68 true <- hashtags_changed?(struct, data_hashtags_change),
69 {:ok, hashtag_records} <-
70 data_hashtags_change
71 |> object_data_hashtags()
72 |> Hashtag.get_or_create_by_names() do
73 put_assoc(changeset, :hashtags, hashtag_records)
74 else
75 false ->
76 changeset
77
78 {:error, hashtag_changeset} ->
79 failed_hashtag = get_field(hashtag_changeset, :name)
80
81 validate_change(changeset, :data, fn _, _ ->
82 [data: "error referencing hashtag: #{failed_hashtag}"]
83 end)
84 end
85 end
86
87 defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do
88 Enum.sort(embedded_hashtags(struct)) !=
89 Enum.sort(object_data_hashtags(data))
90 end
91
92 defp hashtags_changed?(_, _), do: false
93
94 def get_by_id(nil), do: nil
95 def get_by_id(id), do: Repo.get(Object, id)
96
97 def get_by_id_and_maybe_refetch(id, opts \\ []) do
98 %{updated_at: updated_at} = object = get_by_id(id)
99
100 if opts[:interval] &&
101 NaiveDateTime.diff(NaiveDateTime.utc_now(), updated_at) > opts[:interval] do
102 case Fetcher.refetch_object(object) do
103 {:ok, %Object{} = object} ->
104 object
105
106 e ->
107 Logger.error("Couldn't refresh #{object.data["id"]}:\n#{inspect(e)}")
108 object
109 end
110 else
111 object
112 end
113 end
114
115 def get_by_ap_id(nil), do: nil
116
117 def get_by_ap_id(ap_id) do
118 Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id)))
119 end
120
121 @doc """
122 Get a single attachment by it's name and href
123 """
124 @spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil
125 def get_attachment_by_name_and_href(name, href) do
126 query =
127 from(o in Object,
128 where: fragment("(?)->>'name' = ?", o.data, ^name),
129 where: fragment("(?)->>'href' = ?", o.data, ^href)
130 )
131
132 Repo.one(query)
133 end
134
135 defp warn_on_no_object_preloaded(ap_id) do
136 "Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object"
137 |> Logger.debug()
138
139 Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}")
140 end
141
142 def normalize(_, fetch_remote \\ true, options \\ [])
143
144 # If we pass an Activity to Object.normalize(), we can try to use the preloaded object.
145 # Use this whenever possible, especially when walking graphs in an O(N) loop!
146 def normalize(%Object{} = object, _, _), do: object
147 def normalize(%Activity{object: %Object{} = object}, _, _), do: object
148
149 # A hack for fake activities
150 def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}, _, _) do
151 %Object{id: "pleroma:fake_object_id", data: data}
152 end
153
154 # No preloaded object
155 def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}, fetch_remote, _) do
156 warn_on_no_object_preloaded(ap_id)
157 normalize(ap_id, fetch_remote)
158 end
159
160 # No preloaded object
161 def normalize(%Activity{data: %{"object" => ap_id}}, fetch_remote, _) do
162 warn_on_no_object_preloaded(ap_id)
163 normalize(ap_id, fetch_remote)
164 end
165
166 # Old way, try fetching the object through cache.
167 def normalize(%{"id" => ap_id}, fetch_remote, _), do: normalize(ap_id, fetch_remote)
168 def normalize(ap_id, false, _) when is_binary(ap_id), do: get_cached_by_ap_id(ap_id)
169
170 def normalize(ap_id, true, options) when is_binary(ap_id) do
171 Fetcher.fetch_object_from_id!(ap_id, options)
172 end
173
174 def normalize(_, _, _), do: nil
175
176 # Owned objects can only be accessed by their owner
177 def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
178 if actor == ap_id do
179 :ok
180 else
181 {:error, :forbidden}
182 end
183 end
184
185 # Legacy objects can be accessed by anybody
186 def authorize_access(%Object{}, %User{}), do: :ok
187
188 @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
189 def get_cached_by_ap_id(ap_id) do
190 key = "object:#{ap_id}"
191
192 with {:ok, nil} <- Cachex.get(:object_cache, key),
193 object when not is_nil(object) <- get_by_ap_id(ap_id),
194 {:ok, true} <- Cachex.put(:object_cache, key, object) do
195 object
196 else
197 {:ok, object} -> object
198 nil -> nil
199 end
200 end
201
202 def context_mapping(context) do
203 Object.change(%Object{}, %{data: %{"id" => context}})
204 end
205
206 def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do
207 %ObjectTombstone{
208 id: id,
209 formerType: type,
210 deleted: deleted
211 }
212 |> Map.from_struct()
213 end
214
215 def swap_object_with_tombstone(object) do
216 tombstone = make_tombstone(object)
217
218 object
219 |> Object.change(%{data: tombstone})
220 |> Repo.update()
221 end
222
223 def delete(%Object{data: %{"id" => id}} = object) do
224 with {:ok, _obj} = swap_object_with_tombstone(object),
225 deleted_activity = Activity.delete_all_by_object_ap_id(id),
226 {:ok, _} <- invalid_object_cache(object) do
227 cleanup_attachments(
228 Config.get([:instance, :cleanup_attachments]),
229 %{"object" => object}
230 )
231
232 {:ok, object, deleted_activity}
233 end
234 end
235
236 @spec cleanup_attachments(boolean(), %{required(:object) => map()}) ::
237 {:ok, Oban.Job.t() | nil}
238 def cleanup_attachments(true, %{"object" => _} = params) do
239 AttachmentsCleanupWorker.enqueue("cleanup_attachments", params)
240 end
241
242 def cleanup_attachments(_, _), do: {:ok, nil}
243
244 def prune(%Object{data: %{"id" => _id}} = object) do
245 with {:ok, object} <- Repo.delete(object),
246 {:ok, _} <- invalid_object_cache(object) do
247 {:ok, object}
248 end
249 end
250
251 def invalid_object_cache(%Object{data: %{"id" => id}}) do
252 with {:ok, true} <- Cachex.del(:object_cache, "object:#{id}") do
253 Cachex.del(:web_resp_cache, URI.parse(id).path)
254 end
255 end
256
257 def set_cache(%Object{data: %{"id" => ap_id}} = object) do
258 Cachex.put(:object_cache, "object:#{ap_id}", object)
259 {:ok, object}
260 end
261
262 def update_and_set_cache(changeset) do
263 with {:ok, object} <- Repo.update(changeset) do
264 set_cache(object)
265 end
266 end
267
268 def increase_replies_count(ap_id) do
269 Object
270 |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
271 |> update([o],
272 set: [
273 data:
274 fragment(
275 """
276 safe_jsonb_set(?, '{repliesCount}',
277 (coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true)
278 """,
279 o.data,
280 o.data
281 )
282 ]
283 )
284 |> Repo.update_all([])
285 |> case do
286 {1, [object]} -> set_cache(object)
287 _ -> {:error, "Not found"}
288 end
289 end
290
291 defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true
292
293 defp poll_is_multiple?(_), do: false
294
295 def decrease_replies_count(ap_id) do
296 Object
297 |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
298 |> update([o],
299 set: [
300 data:
301 fragment(
302 """
303 safe_jsonb_set(?, '{repliesCount}',
304 (greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true)
305 """,
306 o.data,
307 o.data
308 )
309 ]
310 )
311 |> Repo.update_all([])
312 |> case do
313 {1, [object]} -> set_cache(object)
314 _ -> {:error, "Not found"}
315 end
316 end
317
318 def increase_vote_count(ap_id, name, actor) do
319 with %Object{} = object <- Object.normalize(ap_id),
320 "Question" <- object.data["type"] do
321 key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf"
322
323 options =
324 object.data[key]
325 |> Enum.map(fn
326 %{"name" => ^name} = option ->
327 Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1))
328
329 option ->
330 option
331 end)
332
333 voters = [actor | object.data["voters"] || []] |> Enum.uniq()
334
335 data =
336 object.data
337 |> Map.put(key, options)
338 |> Map.put("voters", voters)
339
340 object
341 |> Object.change(%{data: data})
342 |> update_and_set_cache()
343 else
344 _ -> :noop
345 end
346 end
347
348 @doc "Updates data field of an object"
349 def update_data(%Object{data: data} = object, attrs \\ %{}) do
350 object
351 |> Object.change(%{data: Map.merge(data || %{}, attrs)})
352 |> Repo.update()
353 end
354
355 def local?(%Object{data: %{"id" => id}}) do
356 String.starts_with?(id, Pleroma.Web.base_url() <> "/")
357 end
358
359 def replies(object, opts \\ []) do
360 object = Object.normalize(object)
361
362 query =
363 Object
364 |> where(
365 [o],
366 fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
367 )
368 |> order_by([o], asc: o.id)
369
370 if opts[:self_only] do
371 actor = object.data["actor"]
372 where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
373 else
374 query
375 end
376 end
377
378 def self_replies(object, opts \\ []),
379 do: replies(object, Keyword.put(opts, :self_only, true))
380
381 def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
382
383 def tags(_), do: []
384
385 def hashtags(object), do: embedded_hashtags(object)
386
387 defp embedded_hashtags(%Object{data: data}) do
388 object_data_hashtags(data)
389 end
390
391 defp embedded_hashtags(_), do: []
392
393 defp object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
394 # Note: AS2 map-type elements are ignored
395 Enum.filter(tags, &is_bitstring(&1))
396 end
397
398 defp object_data_hashtags(_), do: []
399 end