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.

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