00af77f5764388c987ef4a29b5fc63c81363ce67
[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])
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 if Keyword.get(options, :fetch) do
177 Fetcher.fetch_object_from_id!(ap_id, options)
178 else
179 get_cached_by_ap_id(ap_id)
180 end
181 end
182
183 def normalize(_, _), do: nil
184
185 # Owned objects can only be accessed by their owner
186 def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
187 if actor == ap_id do
188 :ok
189 else
190 {:error, :forbidden}
191 end
192 end
193
194 # Legacy objects can be accessed by anybody
195 def authorize_access(%Object{}, %User{}), do: :ok
196
197 @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
198 def get_cached_by_ap_id(ap_id) do
199 key = "object:#{ap_id}"
200
201 with {:ok, nil} <- @cachex.get(:object_cache, key),
202 object when not is_nil(object) <- get_by_ap_id(ap_id),
203 {:ok, true} <- @cachex.put(:object_cache, key, object) do
204 object
205 else
206 {:ok, object} -> object
207 nil -> nil
208 end
209 end
210
211 def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do
212 %ObjectTombstone{
213 id: id,
214 formerType: type,
215 deleted: deleted
216 }
217 |> Map.from_struct()
218 end
219
220 def swap_object_with_tombstone(object) do
221 tombstone = make_tombstone(object)
222
223 with {:ok, object} <-
224 object
225 |> Object.change(%{data: tombstone})
226 |> Repo.update() do
227 Hashtag.unlink(object)
228 {:ok, object}
229 end
230 end
231
232 def delete(%Object{data: %{"id" => id}} = object) do
233 with {:ok, _obj} = swap_object_with_tombstone(object),
234 deleted_activity = Activity.delete_all_by_object_ap_id(id),
235 {:ok, _} <- invalid_object_cache(object) do
236 cleanup_attachments(
237 Config.get([:instance, :cleanup_attachments]),
238 %{"object" => object}
239 )
240
241 {:ok, object, deleted_activity}
242 end
243 end
244
245 @spec cleanup_attachments(boolean(), %{required(:object) => map()}) ::
246 {:ok, Oban.Job.t() | nil}
247 def cleanup_attachments(true, %{"object" => _} = params) do
248 AttachmentsCleanupWorker.enqueue("cleanup_attachments", params)
249 end
250
251 def cleanup_attachments(_, _), do: {:ok, nil}
252
253 def prune(%Object{data: %{"id" => _id}} = object) do
254 with {:ok, object} <- Repo.delete(object),
255 {:ok, _} <- invalid_object_cache(object) do
256 {:ok, object}
257 end
258 end
259
260 def invalid_object_cache(%Object{data: %{"id" => id}}) do
261 with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do
262 @cachex.del(:web_resp_cache, URI.parse(id).path)
263 end
264 end
265
266 def set_cache(%Object{data: %{"id" => ap_id}} = object) do
267 @cachex.put(:object_cache, "object:#{ap_id}", object)
268 {:ok, object}
269 end
270
271 def update_and_set_cache(changeset) do
272 with {:ok, object} <- Repo.update(changeset) do
273 set_cache(object)
274 end
275 end
276
277 def increase_replies_count(ap_id) do
278 Object
279 |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
280 |> update([o],
281 set: [
282 data:
283 fragment(
284 """
285 safe_jsonb_set(?, '{repliesCount}',
286 (coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true)
287 """,
288 o.data,
289 o.data
290 )
291 ]
292 )
293 |> Repo.update_all([])
294 |> case do
295 {1, [object]} -> set_cache(object)
296 _ -> {:error, "Not found"}
297 end
298 end
299
300 defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true
301
302 defp poll_is_multiple?(_), do: false
303
304 def decrease_replies_count(ap_id) do
305 Object
306 |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
307 |> update([o],
308 set: [
309 data:
310 fragment(
311 """
312 safe_jsonb_set(?, '{repliesCount}',
313 (greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true)
314 """,
315 o.data,
316 o.data
317 )
318 ]
319 )
320 |> Repo.update_all([])
321 |> case do
322 {1, [object]} -> set_cache(object)
323 _ -> {:error, "Not found"}
324 end
325 end
326
327 def increase_vote_count(ap_id, name, actor) do
328 with %Object{} = object <- Object.normalize(ap_id, fetch: false),
329 "Question" <- object.data["type"] do
330 key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf"
331
332 options =
333 object.data[key]
334 |> Enum.map(fn
335 %{"name" => ^name} = option ->
336 Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1))
337
338 option ->
339 option
340 end)
341
342 voters = [actor | object.data["voters"] || []] |> Enum.uniq()
343
344 data =
345 object.data
346 |> Map.put(key, options)
347 |> Map.put("voters", voters)
348
349 object
350 |> Object.change(%{data: data})
351 |> update_and_set_cache()
352 else
353 _ -> :noop
354 end
355 end
356
357 @doc "Updates data field of an object"
358 def update_data(%Object{data: data} = object, attrs \\ %{}) do
359 object
360 |> Object.change(%{data: Map.merge(data || %{}, attrs)})
361 |> Repo.update()
362 end
363
364 def local?(%Object{data: %{"id" => id}}) do
365 String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
366 end
367
368 def replies(object, opts \\ []) do
369 object = Object.normalize(object, fetch: false)
370
371 query =
372 Object
373 |> where(
374 [o],
375 fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
376 )
377 |> order_by([o], asc: o.id)
378
379 if opts[:self_only] do
380 actor = object.data["actor"]
381 where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
382 else
383 query
384 end
385 end
386
387 def self_replies(object, opts \\ []),
388 do: replies(object, Keyword.put(opts, :self_only, true))
389
390 def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
391
392 def tags(_), do: []
393
394 def hashtags(%Object{} = object) do
395 # Note: always using embedded hashtags regardless whether they are migrated to hashtags table
396 # (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle)
397 embedded_hashtags(object)
398 end
399
400 def embedded_hashtags(%Object{data: data}) do
401 object_data_hashtags(data)
402 end
403
404 def embedded_hashtags(_), do: []
405
406 def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
407 tags
408 |> Enum.filter(fn
409 %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name")
410 plain_text when is_bitstring(plain_text) -> true
411 _ -> false
412 end)
413 |> Enum.map(fn
414 %{"name" => "#" <> hashtag} -> String.downcase(hashtag)
415 %{"name" => hashtag} -> String.downcase(hashtag)
416 hashtag when is_bitstring(hashtag) -> String.downcase(hashtag)
417 end)
418 |> Enum.uniq()
419 # Note: "" elements (plain text) might occur in `data.tag` for incoming objects
420 |> Enum.filter(&(&1 not in [nil, ""]))
421 end
422
423 def object_data_hashtags(_), do: []
424 end