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