logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://anongit.hacktivis.me/git/pleroma.git/

object.ex (13254B)


  1. # Pleroma: A lightweight social networking server
  2. # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
  3. # SPDX-License-Identifier: AGPL-3.0-only
  4. defmodule Pleroma.Object do
  5. use Ecto.Schema
  6. import Ecto.Query
  7. import Ecto.Changeset
  8. alias Pleroma.Activity
  9. alias Pleroma.Config
  10. alias Pleroma.Hashtag
  11. alias Pleroma.Object
  12. alias Pleroma.Object.Fetcher
  13. alias Pleroma.ObjectTombstone
  14. alias Pleroma.Repo
  15. alias Pleroma.User
  16. alias Pleroma.Workers.AttachmentsCleanupWorker
  17. require Logger
  18. @type t() :: %__MODULE__{}
  19. @derive {Jason.Encoder, only: [:data]}
  20. @cachex Pleroma.Config.get([:cachex, :provider], Cachex)
  21. schema "objects" do
  22. field(:data, :map)
  23. many_to_many(:hashtags, Hashtag, join_through: "hashtags_objects", on_replace: :delete)
  24. timestamps()
  25. end
  26. def with_joined_activity(query, activity_type \\ "Create", join_type \\ :inner) do
  27. object_position = Map.get(query.aliases, :object, 0)
  28. join(query, join_type, [{object, object_position}], a in Activity,
  29. on:
  30. fragment(
  31. "associated_object_id(?) = (? ->> 'id') AND (?->>'type' = ?) ",
  32. a.data,
  33. object.data,
  34. a.data,
  35. ^activity_type
  36. ),
  37. as: :object_activity
  38. )
  39. end
  40. def create(data) do
  41. %Object{}
  42. |> Object.change(%{data: data})
  43. |> Repo.insert()
  44. end
  45. def change(struct, params \\ %{}) do
  46. struct
  47. |> cast(params, [:data])
  48. |> validate_required([:data])
  49. |> unique_constraint(:ap_id, name: :objects_unique_apid_index)
  50. # Expecting `maybe_handle_hashtags_change/1` to run last:
  51. |> maybe_handle_hashtags_change(struct)
  52. end
  53. # Note: not checking activity type (assuming non-legacy objects are associated with Create act.)
  54. defp maybe_handle_hashtags_change(changeset, struct) do
  55. with %Ecto.Changeset{valid?: true} <- changeset,
  56. data_hashtags_change = get_change(changeset, :data),
  57. {_, true} <- {:changed, hashtags_changed?(struct, data_hashtags_change)},
  58. {:ok, hashtag_records} <-
  59. data_hashtags_change
  60. |> object_data_hashtags()
  61. |> Hashtag.get_or_create_by_names() do
  62. put_assoc(changeset, :hashtags, hashtag_records)
  63. else
  64. %{valid?: false} ->
  65. changeset
  66. {:changed, false} ->
  67. changeset
  68. {:error, _} ->
  69. validate_change(changeset, :data, fn _, _ ->
  70. [data: "error referencing hashtags"]
  71. end)
  72. end
  73. end
  74. defp hashtags_changed?(%Object{} = struct, %{"tag" => _} = data) do
  75. Enum.sort(embedded_hashtags(struct)) !=
  76. Enum.sort(object_data_hashtags(data))
  77. end
  78. defp hashtags_changed?(_, _), do: false
  79. def get_by_id(nil), do: nil
  80. def get_by_id(id), do: Repo.get(Object, id)
  81. def get_by_ap_id(nil), do: nil
  82. def get_by_ap_id(ap_id) do
  83. Repo.one(from(object in Object, where: fragment("(?)->>'id' = ?", object.data, ^ap_id)))
  84. end
  85. @doc """
  86. Get a single attachment by it's name and href
  87. """
  88. @spec get_attachment_by_name_and_href(String.t(), String.t()) :: Object.t() | nil
  89. def get_attachment_by_name_and_href(name, href) do
  90. query =
  91. from(o in Object,
  92. where: fragment("(?)->>'name' = ?", o.data, ^name),
  93. where: fragment("(?)->>'href' = ?", o.data, ^href)
  94. )
  95. Repo.one(query)
  96. end
  97. defp warn_on_no_object_preloaded(ap_id) do
  98. "Object.normalize() called without preloaded object (#{inspect(ap_id)}). Consider preloading the object"
  99. |> Logger.debug()
  100. Logger.debug("Backtrace: #{inspect(Process.info(:erlang.self(), :current_stacktrace))}")
  101. end
  102. def normalize(_, options \\ [fetch: false, id_only: false])
  103. # If we pass an Activity to Object.normalize(), we can try to use the preloaded object.
  104. # Use this whenever possible, especially when walking graphs in an O(N) loop!
  105. def normalize(%Object{} = object, _), do: object
  106. def normalize(%Activity{object: %Object{} = object}, _), do: object
  107. # A hack for fake activities
  108. def normalize(%Activity{data: %{"object" => %{"fake" => true} = data}}, _) do
  109. %Object{id: "pleroma:fake_object_id", data: data}
  110. end
  111. # No preloaded object
  112. def normalize(%Activity{data: %{"object" => %{"id" => ap_id}}}, options) do
  113. warn_on_no_object_preloaded(ap_id)
  114. normalize(ap_id, options)
  115. end
  116. # No preloaded object
  117. def normalize(%Activity{data: %{"object" => ap_id}}, options) do
  118. warn_on_no_object_preloaded(ap_id)
  119. normalize(ap_id, options)
  120. end
  121. # Old way, try fetching the object through cache.
  122. def normalize(%{"id" => ap_id}, options), do: normalize(ap_id, options)
  123. def normalize(ap_id, options) when is_binary(ap_id) do
  124. cond do
  125. Keyword.get(options, :id_only) ->
  126. ap_id
  127. Keyword.get(options, :fetch) ->
  128. case Fetcher.fetch_object_from_id(ap_id, options) do
  129. {:ok, object} -> object
  130. _ -> nil
  131. end
  132. true ->
  133. get_cached_by_ap_id(ap_id)
  134. end
  135. end
  136. def normalize(_, _), do: nil
  137. # Owned objects can only be accessed by their owner
  138. def authorize_access(%Object{data: %{"actor" => actor}}, %User{ap_id: ap_id}) do
  139. if actor == ap_id do
  140. :ok
  141. else
  142. {:error, :forbidden}
  143. end
  144. end
  145. # Legacy objects can be accessed by anybody
  146. def authorize_access(%Object{}, %User{}), do: :ok
  147. @spec get_cached_by_ap_id(String.t()) :: Object.t() | nil
  148. def get_cached_by_ap_id(ap_id) do
  149. key = "object:#{ap_id}"
  150. with {:ok, nil} <- @cachex.get(:object_cache, key),
  151. object when not is_nil(object) <- get_by_ap_id(ap_id),
  152. {:ok, true} <- @cachex.put(:object_cache, key, object) do
  153. object
  154. else
  155. {:ok, object} -> object
  156. nil -> nil
  157. end
  158. end
  159. def make_tombstone(%Object{data: %{"id" => id, "type" => type}}, deleted \\ DateTime.utc_now()) do
  160. %ObjectTombstone{
  161. id: id,
  162. formerType: type,
  163. deleted: deleted
  164. }
  165. |> Map.from_struct()
  166. end
  167. def swap_object_with_tombstone(object) do
  168. tombstone = make_tombstone(object)
  169. with {:ok, object} <-
  170. object
  171. |> Object.change(%{data: tombstone})
  172. |> Repo.update() do
  173. Hashtag.unlink(object)
  174. {:ok, object}
  175. end
  176. end
  177. def delete(%Object{data: %{"id" => id}} = object) do
  178. with {:ok, _obj} = swap_object_with_tombstone(object),
  179. deleted_activity = Activity.delete_all_by_object_ap_id(id),
  180. {:ok, _} <- invalid_object_cache(object) do
  181. cleanup_attachments(
  182. Config.get([:instance, :cleanup_attachments]),
  183. object
  184. )
  185. {:ok, object, deleted_activity}
  186. end
  187. end
  188. @spec cleanup_attachments(boolean(), Object.t()) ::
  189. {:ok, Oban.Job.t() | nil}
  190. def cleanup_attachments(true, %Object{} = object) do
  191. AttachmentsCleanupWorker.new(%{"op" => "cleanup_attachments", "object" => object})
  192. |> Oban.insert()
  193. end
  194. def cleanup_attachments(_, _), do: {:ok, nil}
  195. def prune(%Object{data: %{"id" => _id}} = object) do
  196. with {:ok, object} <- Repo.delete(object),
  197. {:ok, _} <- invalid_object_cache(object) do
  198. {:ok, object}
  199. end
  200. end
  201. def invalid_object_cache(%Object{data: %{"id" => id}}) do
  202. with {:ok, true} <- @cachex.del(:object_cache, "object:#{id}") do
  203. @cachex.del(:web_resp_cache, URI.parse(id).path)
  204. end
  205. end
  206. def set_cache(%Object{data: %{"id" => ap_id}} = object) do
  207. @cachex.put(:object_cache, "object:#{ap_id}", object)
  208. {:ok, object}
  209. end
  210. def update_and_set_cache(changeset) do
  211. with {:ok, object} <- Repo.update(changeset) do
  212. set_cache(object)
  213. end
  214. end
  215. def increase_replies_count(ap_id) do
  216. Object
  217. |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
  218. |> update([o],
  219. set: [
  220. data:
  221. fragment(
  222. """
  223. safe_jsonb_set(?, '{repliesCount}',
  224. (coalesce((?->>'repliesCount')::int, 0) + 1)::varchar::jsonb, true)
  225. """,
  226. o.data,
  227. o.data
  228. )
  229. ]
  230. )
  231. |> Repo.update_all([])
  232. |> case do
  233. {1, [object]} -> set_cache(object)
  234. _ -> {:error, "Not found"}
  235. end
  236. end
  237. defp poll_is_multiple?(%Object{data: %{"anyOf" => [_ | _]}}), do: true
  238. defp poll_is_multiple?(_), do: false
  239. def decrease_replies_count(ap_id) do
  240. Object
  241. |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
  242. |> update([o],
  243. set: [
  244. data:
  245. fragment(
  246. """
  247. safe_jsonb_set(?, '{repliesCount}',
  248. (greatest(0, (?->>'repliesCount')::int - 1))::varchar::jsonb, true)
  249. """,
  250. o.data,
  251. o.data
  252. )
  253. ]
  254. )
  255. |> Repo.update_all([])
  256. |> case do
  257. {1, [object]} -> set_cache(object)
  258. _ -> {:error, "Not found"}
  259. end
  260. end
  261. def increase_quotes_count(ap_id) do
  262. Object
  263. |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
  264. |> update([o],
  265. set: [
  266. data:
  267. fragment(
  268. """
  269. safe_jsonb_set(?, '{quotesCount}',
  270. (coalesce((?->>'quotesCount')::int, 0) + 1)::varchar::jsonb, true)
  271. """,
  272. o.data,
  273. o.data
  274. )
  275. ]
  276. )
  277. |> Repo.update_all([])
  278. |> case do
  279. {1, [object]} -> set_cache(object)
  280. _ -> {:error, "Not found"}
  281. end
  282. end
  283. def decrease_quotes_count(ap_id) do
  284. Object
  285. |> where([o], fragment("?->>'id' = ?::text", o.data, ^to_string(ap_id)))
  286. |> update([o],
  287. set: [
  288. data:
  289. fragment(
  290. """
  291. safe_jsonb_set(?, '{quotesCount}',
  292. (greatest(0, (?->>'quotesCount')::int - 1))::varchar::jsonb, true)
  293. """,
  294. o.data,
  295. o.data
  296. )
  297. ]
  298. )
  299. |> Repo.update_all([])
  300. |> case do
  301. {1, [object]} -> set_cache(object)
  302. _ -> {:error, "Not found"}
  303. end
  304. end
  305. def increase_vote_count(ap_id, name, actor) do
  306. with %Object{} = object <- Object.normalize(ap_id, fetch: false),
  307. "Question" <- object.data["type"] do
  308. key = if poll_is_multiple?(object), do: "anyOf", else: "oneOf"
  309. options =
  310. object.data[key]
  311. |> Enum.map(fn
  312. %{"name" => ^name} = option ->
  313. Kernel.update_in(option["replies"]["totalItems"], &(&1 + 1))
  314. option ->
  315. option
  316. end)
  317. voters = [actor | object.data["voters"] || []] |> Enum.uniq()
  318. data =
  319. object.data
  320. |> Map.put(key, options)
  321. |> Map.put("voters", voters)
  322. object
  323. |> Object.change(%{data: data})
  324. |> update_and_set_cache()
  325. else
  326. _ -> :noop
  327. end
  328. end
  329. @doc "Updates data field of an object"
  330. def update_data(%Object{data: data} = object, attrs \\ %{}) do
  331. object
  332. |> Object.change(%{data: Map.merge(data || %{}, attrs)})
  333. |> Repo.update()
  334. end
  335. def local?(%Object{data: %{"id" => id}}) do
  336. String.starts_with?(id, Pleroma.Web.Endpoint.url() <> "/")
  337. end
  338. def replies(object, opts \\ []) do
  339. object = Object.normalize(object, fetch: false)
  340. query =
  341. Object
  342. |> where(
  343. [o],
  344. fragment("(?)->>'inReplyTo' = ?", o.data, ^object.data["id"])
  345. )
  346. |> order_by([o], asc: o.id)
  347. if opts[:self_only] do
  348. actor = object.data["actor"]
  349. where(query, [o], fragment("(?)->>'actor' = ?", o.data, ^actor))
  350. else
  351. query
  352. end
  353. end
  354. def self_replies(object, opts \\ []),
  355. do: replies(object, Keyword.put(opts, :self_only, true))
  356. def tags(%Object{data: %{"tag" => tags}}) when is_list(tags), do: tags
  357. def tags(_), do: []
  358. def hashtags(%Object{} = object) do
  359. # Note: always using embedded hashtags regardless whether they are migrated to hashtags table
  360. # (embedded hashtags stay in sync anyways, and we avoid extra joins and preload hassle)
  361. embedded_hashtags(object)
  362. end
  363. def embedded_hashtags(%Object{data: data}) do
  364. object_data_hashtags(data)
  365. end
  366. def embedded_hashtags(_), do: []
  367. def object_data_hashtags(%{"tag" => tags}) when is_list(tags) do
  368. tags
  369. |> Enum.filter(fn
  370. %{"type" => "Hashtag"} = data -> Map.has_key?(data, "name")
  371. plain_text when is_bitstring(plain_text) -> true
  372. _ -> false
  373. end)
  374. |> Enum.map(fn
  375. %{"name" => "#" <> hashtag} -> String.downcase(hashtag)
  376. %{"name" => hashtag} -> String.downcase(hashtag)
  377. hashtag when is_bitstring(hashtag) -> String.downcase(hashtag)
  378. end)
  379. |> Enum.uniq()
  380. # Note: "" elements (plain text) might occur in `data.tag` for incoming objects
  381. |> Enum.filter(&(&1 not in [nil, ""]))
  382. end
  383. def object_data_hashtags(_), do: []
  384. def get_emoji_reactions(object) do
  385. reactions = object.data["reactions"]
  386. if is_list(reactions) or is_map(reactions) do
  387. reactions
  388. |> Enum.map(fn
  389. [_emoji, users, _maybe_url] = item when is_list(users) ->
  390. item
  391. [emoji, users] when is_list(users) ->
  392. [emoji, users, nil]
  393. # This case is here to process the Map situation, which will happen
  394. # only with the legacy two-value format.
  395. {emoji, users} when is_list(users) ->
  396. [emoji, users, nil]
  397. _ ->
  398. nil
  399. end)
  400. |> Enum.reject(&is_nil/1)
  401. else
  402. []
  403. end
  404. end
  405. end