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