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.

542 lines
17KB

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