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