Fork of Pleroma with site-specific changes and feature branches https://git.pleroma.social/pleroma/pleroma
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

605 lines
18KB

  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. defmodule Pleroma.Web.MastodonAPI.StatusView do
  5. use Pleroma.Web, :view
  6. require Pleroma.Constants
  7. alias Pleroma.Activity
  8. alias Pleroma.HTML
  9. alias Pleroma.Maps
  10. alias Pleroma.Object
  11. alias Pleroma.Repo
  12. alias Pleroma.User
  13. alias Pleroma.UserRelationship
  14. alias Pleroma.Web.CommonAPI
  15. alias Pleroma.Web.CommonAPI.Utils
  16. alias Pleroma.Web.MastodonAPI.AccountView
  17. alias Pleroma.Web.MastodonAPI.PollView
  18. alias Pleroma.Web.MastodonAPI.StatusView
  19. alias Pleroma.Web.MediaProxy
  20. alias Pleroma.Web.PleromaAPI.EmojiReactionController
  21. import Pleroma.Web.ActivityPub.Visibility, only: [get_visibility: 1, visible_for_user?: 2]
  22. # This is a naive way to do this, just spawning a process per activity
  23. # to fetch the preview. However it should be fine considering
  24. # pagination is restricted to 40 activities at a time
  25. defp fetch_rich_media_for_activities(activities) do
  26. Enum.each(activities, fn activity ->
  27. spawn(fn ->
  28. Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity)
  29. end)
  30. end)
  31. end
  32. # TODO: Add cached version.
  33. defp get_replied_to_activities([]), do: %{}
  34. defp get_replied_to_activities(activities) do
  35. activities
  36. |> Enum.map(fn
  37. %{data: %{"type" => "Create"}} = activity ->
  38. object = Object.normalize(activity, fetch: false)
  39. object && object.data["inReplyTo"] != "" && object.data["inReplyTo"]
  40. _ ->
  41. nil
  42. end)
  43. |> Enum.filter(& &1)
  44. |> Activity.create_by_object_ap_id_with_object()
  45. |> Repo.all()
  46. |> Enum.reduce(%{}, fn activity, acc ->
  47. object = Object.normalize(activity, fetch: false)
  48. if object, do: Map.put(acc, object.data["id"], activity), else: acc
  49. end)
  50. end
  51. defp get_context_id(%{data: %{"context_id" => context_id}}) when not is_nil(context_id),
  52. do: context_id
  53. defp get_context_id(%{data: %{"context" => context}}) when is_binary(context),
  54. do: Utils.context_to_conversation_id(context)
  55. defp get_context_id(_), do: nil
  56. # Check if the user reblogged this status
  57. defp reblogged?(activity, %User{ap_id: ap_id}) do
  58. with %Object{data: %{"announcements" => announcements}} when is_list(announcements) <-
  59. Object.normalize(activity, fetch: false) do
  60. ap_id in announcements
  61. else
  62. _ -> false
  63. end
  64. end
  65. # False if the user is logged out
  66. defp reblogged?(_activity, _user), do: false
  67. def render("index.json", opts) do
  68. reading_user = opts[:for]
  69. # To do: check AdminAPIControllerTest on the reasons behind nil activities in the list
  70. activities = Enum.filter(opts.activities, & &1)
  71. # Start fetching rich media before doing anything else, so that later calls to get the cards
  72. # only block for timeout in the worst case, as opposed to
  73. # length(activities_with_links) * timeout
  74. fetch_rich_media_for_activities(activities)
  75. replied_to_activities = get_replied_to_activities(activities)
  76. parent_activities =
  77. activities
  78. |> Enum.filter(&(&1.data["type"] == "Announce" && &1.data["object"]))
  79. |> Enum.map(&Object.normalize(&1, fetch: false).data["id"])
  80. |> Activity.create_by_object_ap_id()
  81. |> Activity.with_preloaded_object(:left)
  82. |> Activity.with_preloaded_bookmark(reading_user)
  83. |> Activity.with_set_thread_muted_field(reading_user)
  84. |> Repo.all()
  85. relationships_opt =
  86. cond do
  87. Map.has_key?(opts, :relationships) ->
  88. opts[:relationships]
  89. is_nil(reading_user) ->
  90. UserRelationship.view_relationships_option(nil, [])
  91. true ->
  92. # Note: unresolved users are filtered out
  93. actors =
  94. (activities ++ parent_activities)
  95. |> Enum.map(&CommonAPI.get_user(&1.data["actor"], false))
  96. |> Enum.filter(& &1)
  97. UserRelationship.view_relationships_option(reading_user, actors, subset: :source_mutes)
  98. end
  99. opts =
  100. opts
  101. |> Map.put(:replied_to_activities, replied_to_activities)
  102. |> Map.put(:parent_activities, parent_activities)
  103. |> Map.put(:relationships, relationships_opt)
  104. safe_render_many(activities, StatusView, "show.json", opts)
  105. end
  106. def render(
  107. "show.json",
  108. %{activity: %{data: %{"type" => "Announce", "object" => _object}} = activity} = opts
  109. ) do
  110. user = CommonAPI.get_user(activity.data["actor"])
  111. created_at = Utils.to_masto_date(activity.data["published"])
  112. object = Object.normalize(activity, fetch: false)
  113. reblogged_parent_activity =
  114. if opts[:parent_activities] do
  115. Activity.Queries.find_by_object_ap_id(
  116. opts[:parent_activities],
  117. object.data["id"]
  118. )
  119. else
  120. Activity.create_by_object_ap_id(object.data["id"])
  121. |> Activity.with_preloaded_bookmark(opts[:for])
  122. |> Activity.with_set_thread_muted_field(opts[:for])
  123. |> Repo.one()
  124. end
  125. reblog_rendering_opts = Map.put(opts, :activity, reblogged_parent_activity)
  126. reblogged = render("show.json", reblog_rendering_opts)
  127. favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
  128. bookmarked = Activity.get_bookmark(reblogged_parent_activity, opts[:for]) != nil
  129. mentions =
  130. activity.recipients
  131. |> Enum.map(fn ap_id -> User.get_cached_by_ap_id(ap_id) end)
  132. |> Enum.filter(& &1)
  133. |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
  134. {pinned?, pinned_at} = pin_data(object, user)
  135. %{
  136. id: to_string(activity.id),
  137. uri: object.data["id"],
  138. url: object.data["id"],
  139. account:
  140. AccountView.render("show.json", %{
  141. user: user,
  142. for: opts[:for]
  143. }),
  144. in_reply_to_id: nil,
  145. in_reply_to_account_id: nil,
  146. reblog: reblogged,
  147. content: reblogged[:content] || "",
  148. created_at: created_at,
  149. reblogs_count: 0,
  150. replies_count: 0,
  151. favourites_count: 0,
  152. reblogged: reblogged?(reblogged_parent_activity, opts[:for]),
  153. favourited: present?(favorited),
  154. bookmarked: present?(bookmarked),
  155. muted: false,
  156. pinned: pinned?,
  157. sensitive: false,
  158. spoiler_text: "",
  159. visibility: get_visibility(activity),
  160. media_attachments: reblogged[:media_attachments] || [],
  161. mentions: mentions,
  162. tags: reblogged[:tags] || [],
  163. application: build_application(object.data["generator"]),
  164. language: nil,
  165. emojis: [],
  166. pleroma: %{
  167. local: activity.local,
  168. pinned_at: pinned_at
  169. }
  170. }
  171. end
  172. def render("show.json", %{activity: %{data: %{"object" => _object}} = activity} = opts) do
  173. object = Object.normalize(activity, fetch: false)
  174. user = CommonAPI.get_user(activity.data["actor"])
  175. user_follower_address = user.follower_address
  176. like_count = object.data["like_count"] || 0
  177. announcement_count = object.data["announcement_count"] || 0
  178. hashtags = Object.hashtags(object)
  179. sensitive = object.data["sensitive"] || Enum.member?(hashtags, "nsfw")
  180. tags = Object.tags(object)
  181. tag_mentions =
  182. tags
  183. |> Enum.filter(fn tag -> is_map(tag) and tag["type"] == "Mention" end)
  184. |> Enum.map(fn tag -> tag["href"] end)
  185. mentions =
  186. (object.data["to"] ++ tag_mentions)
  187. |> Enum.uniq()
  188. |> Enum.map(fn
  189. Pleroma.Constants.as_public() -> nil
  190. ^user_follower_address -> nil
  191. ap_id -> User.get_cached_by_ap_id(ap_id)
  192. end)
  193. |> Enum.filter(& &1)
  194. |> Enum.map(fn user -> AccountView.render("mention.json", %{user: user}) end)
  195. favorited = opts[:for] && opts[:for].ap_id in (object.data["likes"] || [])
  196. bookmarked = Activity.get_bookmark(activity, opts[:for]) != nil
  197. client_posted_this_activity = opts[:for] && user.id == opts[:for].id
  198. expires_at =
  199. with true <- client_posted_this_activity,
  200. %Oban.Job{scheduled_at: scheduled_at} <-
  201. Pleroma.Workers.PurgeExpiredActivity.get_expiration(activity.id) do
  202. scheduled_at
  203. else
  204. _ -> nil
  205. end
  206. thread_muted? =
  207. cond do
  208. is_nil(opts[:for]) -> false
  209. is_boolean(activity.thread_muted?) -> activity.thread_muted?
  210. true -> CommonAPI.thread_muted?(opts[:for], activity)
  211. end
  212. attachment_data = object.data["attachment"] || []
  213. attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
  214. created_at = Utils.to_masto_date(object.data["published"])
  215. reply_to = get_reply_to(activity, opts)
  216. reply_to_user = reply_to && CommonAPI.get_user(reply_to.data["actor"])
  217. content =
  218. object
  219. |> render_content()
  220. content_html =
  221. content
  222. |> Activity.HTML.get_cached_scrubbed_html_for_activity(
  223. User.html_filter_policy(opts[:for]),
  224. activity,
  225. "mastoapi:content"
  226. )
  227. content_plaintext =
  228. content
  229. |> Activity.HTML.get_cached_stripped_html_for_activity(
  230. activity,
  231. "mastoapi:content"
  232. )
  233. summary = object.data["summary"] || ""
  234. card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
  235. url =
  236. if user.local do
  237. Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
  238. else
  239. object.data["url"] || object.data["external_url"] || object.data["id"]
  240. end
  241. direct_conversation_id =
  242. with {_, nil} <- {:direct_conversation_id, opts[:direct_conversation_id]},
  243. {_, true} <- {:include_id, opts[:with_direct_conversation_id]},
  244. {_, %User{} = for_user} <- {:for_user, opts[:for]} do
  245. Activity.direct_conversation_id(activity, for_user)
  246. else
  247. {:direct_conversation_id, participation_id} when is_integer(participation_id) ->
  248. participation_id
  249. _e ->
  250. nil
  251. end
  252. emoji_reactions =
  253. object.data
  254. |> Map.get("reactions", [])
  255. |> EmojiReactionController.filter_allowed_users(
  256. opts[:for],
  257. Map.get(opts, :with_muted, false)
  258. )
  259. |> Stream.map(fn {emoji, users} ->
  260. build_emoji_map(emoji, users, opts[:for])
  261. end)
  262. |> Enum.to_list()
  263. # Status muted state (would do 1 request per status unless user mutes are preloaded)
  264. muted =
  265. thread_muted? ||
  266. UserRelationship.exists?(
  267. get_in(opts, [:relationships, :user_relationships]),
  268. :mute,
  269. opts[:for],
  270. user,
  271. fn for_user, user -> User.mutes?(for_user, user) end
  272. )
  273. {pinned?, pinned_at} = pin_data(object, user)
  274. %{
  275. id: to_string(activity.id),
  276. uri: object.data["id"],
  277. url: url,
  278. account:
  279. AccountView.render("show.json", %{
  280. user: user,
  281. for: opts[:for]
  282. }),
  283. in_reply_to_id: reply_to && to_string(reply_to.id),
  284. in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
  285. reblog: nil,
  286. card: card,
  287. content: content_html,
  288. text: opts[:with_source] && object.data["source"],
  289. created_at: created_at,
  290. reblogs_count: announcement_count,
  291. replies_count: object.data["repliesCount"] || 0,
  292. favourites_count: like_count,
  293. reblogged: reblogged?(activity, opts[:for]),
  294. favourited: present?(favorited),
  295. bookmarked: present?(bookmarked),
  296. muted: muted,
  297. pinned: pinned?,
  298. sensitive: sensitive,
  299. spoiler_text: summary,
  300. visibility: get_visibility(object),
  301. media_attachments: attachments,
  302. poll: render(PollView, "show.json", object: object, for: opts[:for]),
  303. mentions: mentions,
  304. tags: build_tags(tags),
  305. application: build_application(object.data["generator"]),
  306. language: nil,
  307. emojis: build_emojis(object.data["emoji"]),
  308. pleroma: %{
  309. local: activity.local,
  310. conversation_id: get_context_id(activity),
  311. in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
  312. content: %{"text/plain" => content_plaintext},
  313. spoiler_text: %{"text/plain" => summary},
  314. expires_at: expires_at,
  315. direct_conversation_id: direct_conversation_id,
  316. thread_muted: thread_muted?,
  317. emoji_reactions: emoji_reactions,
  318. parent_visible: visible_for_user?(reply_to, opts[:for]),
  319. pinned_at: pinned_at
  320. }
  321. }
  322. end
  323. def render("show.json", _) do
  324. nil
  325. end
  326. def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
  327. page_url_data = URI.parse(page_url)
  328. page_url_data =
  329. if is_binary(rich_media["url"]) do
  330. URI.merge(page_url_data, URI.parse(rich_media["url"]))
  331. else
  332. page_url_data
  333. end
  334. page_url = page_url_data |> to_string
  335. image_url_data =
  336. if is_binary(rich_media["image"]) do
  337. URI.parse(rich_media["image"])
  338. else
  339. nil
  340. end
  341. image_url = build_image_url(image_url_data, page_url_data)
  342. %{
  343. type: "link",
  344. provider_name: page_url_data.host,
  345. provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
  346. url: page_url,
  347. image: image_url |> MediaProxy.url(),
  348. title: rich_media["title"] || "",
  349. description: rich_media["description"] || "",
  350. pleroma: %{
  351. opengraph: rich_media
  352. }
  353. }
  354. end
  355. def render("card.json", _), do: nil
  356. def render("attachment.json", %{attachment: attachment}) do
  357. [attachment_url | _] = attachment["url"]
  358. media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
  359. href = attachment_url["href"] |> MediaProxy.url()
  360. href_preview = attachment_url["href"] |> MediaProxy.preview_url()
  361. meta = render("attachment_meta.json", %{attachment: attachment})
  362. type =
  363. cond do
  364. String.contains?(media_type, "image") -> "image"
  365. String.contains?(media_type, "video") -> "video"
  366. String.contains?(media_type, "audio") -> "audio"
  367. true -> "unknown"
  368. end
  369. <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
  370. %{
  371. id: to_string(attachment["id"] || hash_id),
  372. url: href,
  373. remote_url: href,
  374. preview_url: href_preview,
  375. text_url: href,
  376. type: type,
  377. description: attachment["name"],
  378. pleroma: %{mime_type: media_type},
  379. blurhash: attachment["blurhash"]
  380. }
  381. |> Maps.put_if_present(:meta, meta)
  382. end
  383. def render("attachment_meta.json", %{
  384. attachment: %{"url" => [%{"width" => width, "height" => height} | _]}
  385. })
  386. when is_integer(width) and is_integer(height) do
  387. %{
  388. original: %{
  389. width: width,
  390. height: height,
  391. aspect: width / height
  392. }
  393. }
  394. end
  395. def render("attachment_meta.json", _), do: nil
  396. def render("context.json", %{activity: activity, activities: activities, user: user}) do
  397. %{ancestors: ancestors, descendants: descendants} =
  398. activities
  399. |> Enum.reverse()
  400. |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
  401. |> Map.put_new(:ancestors, [])
  402. |> Map.put_new(:descendants, [])
  403. %{
  404. ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
  405. descendants: render("index.json", for: user, activities: descendants, as: :activity)
  406. }
  407. end
  408. def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
  409. object = Object.normalize(activity, fetch: false)
  410. with nil <- replied_to_activities[object.data["inReplyTo"]] do
  411. # If user didn't participate in the thread
  412. Activity.get_in_reply_to_activity(activity)
  413. end
  414. end
  415. def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
  416. object = Object.normalize(activity, fetch: false)
  417. if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
  418. Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
  419. else
  420. nil
  421. end
  422. end
  423. def render_content(%{data: %{"name" => name}} = object) when not is_nil(name) and name != "" do
  424. url = object.data["url"] || object.data["id"]
  425. "<p><a href=\"#{url}\">#{name}</a></p>#{object.data["content"]}"
  426. end
  427. def render_content(object), do: object.data["content"] || ""
  428. @doc """
  429. Builds a dictionary tags.
  430. ## Examples
  431. iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
  432. [{"name": "fediverse", "url": "/tag/fediverse"},
  433. {"name": "nextcloud", "url": "/tag/nextcloud"}]
  434. """
  435. @spec build_tags(list(any())) :: list(map())
  436. def build_tags(object_tags) when is_list(object_tags) do
  437. object_tags
  438. |> Enum.filter(&is_binary/1)
  439. |> Enum.map(&%{name: &1, url: "#{Pleroma.Web.Endpoint.url()}/tag/#{URI.encode(&1)}"})
  440. end
  441. def build_tags(_), do: []
  442. @doc """
  443. Builds list emojis.
  444. Arguments: `nil` or list tuple of name and url.
  445. Returns list emojis.
  446. ## Examples
  447. iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
  448. [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
  449. """
  450. @spec build_emojis(nil | list(tuple())) :: list(map())
  451. def build_emojis(nil), do: []
  452. def build_emojis(emojis) do
  453. emojis
  454. |> Enum.map(fn {name, url} ->
  455. name = HTML.strip_tags(name)
  456. url =
  457. url
  458. |> HTML.strip_tags()
  459. |> MediaProxy.url()
  460. %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
  461. end)
  462. end
  463. defp present?(nil), do: false
  464. defp present?(false), do: false
  465. defp present?(_), do: true
  466. defp pin_data(%Object{data: %{"id" => object_id}}, %User{pinned_objects: pinned_objects}) do
  467. if pinned_at = pinned_objects[object_id] do
  468. {true, Utils.to_masto_date(pinned_at)}
  469. else
  470. {false, nil}
  471. end
  472. end
  473. defp build_emoji_map(emoji, users, current_user) do
  474. %{
  475. name: emoji,
  476. count: length(users),
  477. me: !!(current_user && current_user.ap_id in users)
  478. }
  479. end
  480. @spec build_application(map() | nil) :: map() | nil
  481. defp build_application(%{"type" => _type, "name" => name, "url" => url}),
  482. do: %{name: name, website: url}
  483. defp build_application(_), do: nil
  484. # Workaround for Elixir issue #10771
  485. # Avoid applying URI.merge unless necessary
  486. # TODO: revert to always attempting URI.merge(image_url_data, page_url_data)
  487. # when Elixir 1.12 is the minimum supported version
  488. @spec build_image_url(struct() | nil, struct()) :: String.t() | nil
  489. defp build_image_url(
  490. %URI{scheme: image_scheme, host: image_host} = image_url_data,
  491. %URI{} = _page_url_data
  492. )
  493. when not is_nil(image_scheme) and not is_nil(image_host) do
  494. image_url_data |> to_string
  495. end
  496. defp build_image_url(%URI{} = image_url_data, %URI{} = page_url_data) do
  497. URI.merge(page_url_data, image_url_data) |> to_string
  498. end
  499. defp build_image_url(_, _), do: nil
  500. end