Merge branch 'http-security-warning' into 'develop'
[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 %{
260 emoji: emoji,
261 count: length(users),
262 reacted: !!(opts[:for] && opts[:for].ap_id in users)
263 }
264 end)
265 else
266 _ -> []
267 end
268
269 %{
270 id: to_string(activity.id),
271 uri: object.data["id"],
272 url: url,
273 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
274 in_reply_to_id: reply_to && to_string(reply_to.id),
275 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
276 reblog: nil,
277 card: card,
278 content: content_html,
279 created_at: created_at,
280 reblogs_count: announcement_count,
281 replies_count: object.data["repliesCount"] || 0,
282 favourites_count: like_count,
283 reblogged: reblogged?(activity, opts[:for]),
284 favourited: present?(favorited),
285 bookmarked: present?(bookmarked),
286 muted: thread_muted? || User.mutes?(opts[:for], user),
287 pinned: pinned?(activity, user),
288 sensitive: sensitive,
289 spoiler_text: summary_html,
290 visibility: get_visibility(object),
291 media_attachments: attachments,
292 poll: render(PollView, "show.json", object: object, for: opts[:for]),
293 mentions: mentions,
294 tags: build_tags(tags),
295 application: %{
296 name: "Web",
297 website: nil
298 },
299 language: nil,
300 emojis: build_emojis(object.data["emoji"]),
301 pleroma: %{
302 local: activity.local,
303 conversation_id: get_context_id(activity),
304 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
305 content: %{"text/plain" => content_plaintext},
306 spoiler_text: %{"text/plain" => summary_plaintext},
307 expires_at: expires_at,
308 direct_conversation_id: direct_conversation_id,
309 thread_muted: thread_muted?,
310 emoji_reactions: emoji_reactions
311 }
312 }
313 end
314
315 def render("show.json", _) do
316 nil
317 end
318
319 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
320 page_url_data = URI.parse(page_url)
321
322 page_url_data =
323 if rich_media[:url] != nil do
324 URI.merge(page_url_data, URI.parse(rich_media[:url]))
325 else
326 page_url_data
327 end
328
329 page_url = page_url_data |> to_string
330
331 image_url =
332 if rich_media[:image] != nil do
333 URI.merge(page_url_data, URI.parse(rich_media[:image]))
334 |> to_string
335 else
336 nil
337 end
338
339 site_name = rich_media[:site_name] || page_url_data.host
340
341 %{
342 type: "link",
343 provider_name: site_name,
344 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
345 url: page_url,
346 image: image_url |> MediaProxy.url(),
347 title: rich_media[:title] || "",
348 description: rich_media[:description] || "",
349 pleroma: %{
350 opengraph: rich_media
351 }
352 }
353 end
354
355 def render("card.json", _), do: nil
356
357 def render("attachment.json", %{attachment: attachment}) do
358 [attachment_url | _] = attachment["url"]
359 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
360 href = attachment_url["href"] |> MediaProxy.url()
361
362 type =
363 cond do
364 String.contains?(media_type, "image") -> "image"
365 String.contains?(media_type, "video") -> "video"
366 String.contains?(media_type, "audio") -> "audio"
367 true -> "unknown"
368 end
369
370 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
371
372 %{
373 id: to_string(attachment["id"] || hash_id),
374 url: href,
375 remote_url: href,
376 preview_url: href,
377 text_url: href,
378 type: type,
379 description: attachment["name"],
380 pleroma: %{mime_type: media_type}
381 }
382 end
383
384 def render("listen.json", %{activity: %Activity{data: %{"type" => "Listen"}} = activity} = opts) do
385 object = Object.normalize(activity)
386
387 user = get_user(activity.data["actor"])
388 created_at = Utils.to_masto_date(activity.data["published"])
389
390 %{
391 id: activity.id,
392 account: AccountView.render("show.json", %{user: user, for: opts[:for]}),
393 created_at: created_at,
394 title: object.data["title"] |> HTML.strip_tags(),
395 artist: object.data["artist"] |> HTML.strip_tags(),
396 album: object.data["album"] |> HTML.strip_tags(),
397 length: object.data["length"]
398 }
399 end
400
401 def render("listens.json", opts) do
402 safe_render_many(opts.activities, StatusView, "listen.json", opts)
403 end
404
405 def render("context.json", %{activity: activity, activities: activities, user: user}) do
406 %{ancestors: ancestors, descendants: descendants} =
407 activities
408 |> Enum.reverse()
409 |> Enum.group_by(fn %{id: id} -> if id < activity.id, do: :ancestors, else: :descendants end)
410 |> Map.put_new(:ancestors, [])
411 |> Map.put_new(:descendants, [])
412
413 %{
414 ancestors: render("index.json", for: user, activities: ancestors, as: :activity),
415 descendants: render("index.json", for: user, activities: descendants, as: :activity)
416 }
417 end
418
419 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
420 object = Object.normalize(activity)
421
422 with nil <- replied_to_activities[object.data["inReplyTo"]] do
423 # If user didn't participate in the thread
424 Activity.get_in_reply_to_activity(activity)
425 end
426 end
427
428 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
429 object = Object.normalize(activity)
430
431 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
432 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
433 else
434 nil
435 end
436 end
437
438 def render_content(%{data: %{"type" => object_type}} = object)
439 when object_type in ["Video", "Event"] do
440 with name when not is_nil(name) and name != "" <- object.data["name"] do
441 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
442 else
443 _ -> object.data["content"] || ""
444 end
445 end
446
447 def render_content(%{data: %{"type" => object_type}} = object)
448 when object_type in ["Article", "Page"] do
449 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
450 url when is_bitstring(url) <- object.data["url"] do
451 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
452 else
453 _ -> object.data["content"] || ""
454 end
455 end
456
457 def render_content(object), do: object.data["content"] || ""
458
459 @doc """
460 Builds a dictionary tags.
461
462 ## Examples
463
464 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
465 [{"name": "fediverse", "url": "/tag/fediverse"},
466 {"name": "nextcloud", "url": "/tag/nextcloud"}]
467
468 """
469 @spec build_tags(list(any())) :: list(map())
470 def build_tags(object_tags) when is_list(object_tags) do
471 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
472
473 Enum.reduce(object_tags, [], fn tag, tags ->
474 tags ++ [%{name: tag, url: "/tag/#{URI.encode(tag)}"}]
475 end)
476 end
477
478 def build_tags(_), do: []
479
480 @doc """
481 Builds list emojis.
482
483 Arguments: `nil` or list tuple of name and url.
484
485 Returns list emojis.
486
487 ## Examples
488
489 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
490 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
491
492 """
493 @spec build_emojis(nil | list(tuple())) :: list(map())
494 def build_emojis(nil), do: []
495
496 def build_emojis(emojis) do
497 emojis
498 |> Enum.map(fn {name, url} ->
499 name = HTML.strip_tags(name)
500
501 url =
502 url
503 |> HTML.strip_tags()
504 |> MediaProxy.url()
505
506 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
507 end)
508 end
509
510 defp present?(nil), do: false
511 defp present?(false), do: false
512 defp present?(_), do: true
513
514 defp pinned?(%Activity{id: id}, %User{pinned_activities: pinned_activities}),
515 do: id in pinned_activities
516 end