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