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