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