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