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