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