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