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