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