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