MastoAPI: Add GET /api/v1/polls/:id
[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}),
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 attachment_data = object.data["attachment"] || []
161 attachments = render_many(attachment_data, StatusView, "attachment.json", as: :attachment)
162
163 created_at = Utils.to_masto_date(object.data["published"])
164
165 reply_to = get_reply_to(activity, opts)
166
167 reply_to_user = reply_to && get_user(reply_to.data["actor"])
168
169 content =
170 object
171 |> render_content()
172
173 content_html =
174 content
175 |> HTML.get_cached_scrubbed_html_for_activity(
176 User.html_filter_policy(opts[:for]),
177 activity,
178 "mastoapi:content"
179 )
180
181 content_plaintext =
182 content
183 |> HTML.get_cached_stripped_html_for_activity(
184 activity,
185 "mastoapi:content"
186 )
187
188 summary = object.data["summary"] || ""
189
190 summary_html =
191 summary
192 |> HTML.get_cached_scrubbed_html_for_activity(
193 User.html_filter_policy(opts[:for]),
194 activity,
195 "mastoapi:summary"
196 )
197
198 summary_plaintext =
199 summary
200 |> HTML.get_cached_stripped_html_for_activity(
201 activity,
202 "mastoapi:summary"
203 )
204
205 card = render("card.json", Pleroma.Web.RichMedia.Helpers.fetch_data_for_activity(activity))
206
207 url =
208 if user.local do
209 Pleroma.Web.Router.Helpers.o_status_url(Pleroma.Web.Endpoint, :notice, activity)
210 else
211 object.data["external_url"] || object.data["id"]
212 end
213
214 %{
215 id: to_string(activity.id),
216 uri: object.data["id"],
217 url: url,
218 account: AccountView.render("account.json", %{user: user}),
219 in_reply_to_id: reply_to && to_string(reply_to.id),
220 in_reply_to_account_id: reply_to_user && to_string(reply_to_user.id),
221 reblog: nil,
222 card: card,
223 content: content_html,
224 created_at: created_at,
225 reblogs_count: announcement_count,
226 replies_count: object.data["repliesCount"] || 0,
227 favourites_count: like_count,
228 reblogged: reblogged?(activity, opts[:for]),
229 favourited: present?(favorited),
230 bookmarked: present?(bookmarked),
231 muted: CommonAPI.thread_muted?(user, activity) || User.mutes?(opts[:for], user),
232 pinned: pinned?(activity, user),
233 sensitive: sensitive,
234 spoiler_text: summary_html,
235 visibility: get_visibility(object),
236 media_attachments: attachments,
237 poll: render("poll.json", %{object: object, for: opts[:for]}),
238 mentions: mentions,
239 tags: build_tags(tags),
240 application: %{
241 name: "Web",
242 website: nil
243 },
244 language: nil,
245 emojis: build_emojis(object.data["emoji"]),
246 pleroma: %{
247 local: activity.local,
248 conversation_id: get_context_id(activity),
249 in_reply_to_account_acct: reply_to_user && reply_to_user.nickname,
250 content: %{"text/plain" => content_plaintext},
251 spoiler_text: %{"text/plain" => summary_plaintext}
252 }
253 }
254 end
255
256 def render("status.json", _) do
257 nil
258 end
259
260 def render("card.json", %{rich_media: rich_media, page_url: page_url}) do
261 page_url_data = URI.parse(page_url)
262
263 page_url_data =
264 if rich_media[:url] != nil do
265 URI.merge(page_url_data, URI.parse(rich_media[:url]))
266 else
267 page_url_data
268 end
269
270 page_url = page_url_data |> to_string
271
272 image_url =
273 if rich_media[:image] != nil do
274 URI.merge(page_url_data, URI.parse(rich_media[:image]))
275 |> to_string
276 else
277 nil
278 end
279
280 site_name = rich_media[:site_name] || page_url_data.host
281
282 %{
283 type: "link",
284 provider_name: site_name,
285 provider_url: page_url_data.scheme <> "://" <> page_url_data.host,
286 url: page_url,
287 image: image_url |> MediaProxy.url(),
288 title: rich_media[:title],
289 description: rich_media[:description],
290 pleroma: %{
291 opengraph: rich_media
292 }
293 }
294 end
295
296 def render("card.json", _) do
297 nil
298 end
299
300 def render("attachment.json", %{attachment: attachment}) do
301 [attachment_url | _] = attachment["url"]
302 media_type = attachment_url["mediaType"] || attachment_url["mimeType"] || "image"
303 href = attachment_url["href"] |> MediaProxy.url()
304
305 type =
306 cond do
307 String.contains?(media_type, "image") -> "image"
308 String.contains?(media_type, "video") -> "video"
309 String.contains?(media_type, "audio") -> "audio"
310 true -> "unknown"
311 end
312
313 <<hash_id::signed-32, _rest::binary>> = :crypto.hash(:md5, href)
314
315 %{
316 id: to_string(attachment["id"] || hash_id),
317 url: href,
318 remote_url: href,
319 preview_url: href,
320 text_url: href,
321 type: type,
322 description: attachment["name"],
323 pleroma: %{mime_type: media_type}
324 }
325 end
326
327 # TODO: Add tests for this view
328 def render("poll.json", %{object: object} = _opts) do
329 {multiple, options} =
330 case object.data do
331 %{"anyOf" => options} when is_list(options) -> {true, options}
332 %{"oneOf" => options} when is_list(options) -> {false, options}
333 _ -> {nil, nil}
334 end
335
336 if options do
337 end_time =
338 (object.data["closed"] || object.data["endTime"])
339 |> NaiveDateTime.from_iso8601!()
340
341 expired =
342 end_time
343 |> NaiveDateTime.compare(NaiveDateTime.utc_now())
344 |> case do
345 :lt -> true
346 _ -> false
347 end
348
349 {options, votes_count} =
350 Enum.map_reduce(options, 0, fn %{"name" => name} = option, count ->
351 current_count = option["replies"]["totalItems"] || 0
352
353 {%{
354 title: HTML.strip_tags(name),
355 votes_count: current_count
356 }, current_count + count}
357 end)
358
359 %{
360 # Mastodon uses separate ids for polls, but an object can't have more than one poll embedded so object id is fine
361 id: object.id,
362 expires_at: Utils.to_masto_date(end_time),
363 expired: expired,
364 multiple: multiple,
365 votes_count: votes_count,
366 options: options,
367 # TODO: Actually check for a vote
368 voted: false,
369 emojis: build_emojis(object.data["emoji"])
370 }
371 else
372 nil
373 end
374 end
375
376 def get_reply_to(activity, %{replied_to_activities: replied_to_activities}) do
377 object = Object.normalize(activity)
378
379 with nil <- replied_to_activities[object.data["inReplyTo"]] do
380 # If user didn't participate in the thread
381 Activity.get_in_reply_to_activity(activity)
382 end
383 end
384
385 def get_reply_to(%{data: %{"object" => _object}} = activity, _) do
386 object = Object.normalize(activity)
387
388 if object.data["inReplyTo"] && object.data["inReplyTo"] != "" do
389 Activity.get_create_by_object_ap_id(object.data["inReplyTo"])
390 else
391 nil
392 end
393 end
394
395 def render_content(%{data: %{"type" => "Video"}} = object) do
396 with name when not is_nil(name) and name != "" <- object.data["name"] do
397 "<p><a href=\"#{object.data["id"]}\">#{name}</a></p>#{object.data["content"]}"
398 else
399 _ -> object.data["content"] || ""
400 end
401 end
402
403 def render_content(%{data: %{"type" => object_type}} = object)
404 when object_type in ["Article", "Page"] do
405 with summary when not is_nil(summary) and summary != "" <- object.data["name"],
406 url when is_bitstring(url) <- object.data["url"] do
407 "<p><a href=\"#{url}\">#{summary}</a></p>#{object.data["content"]}"
408 else
409 _ -> object.data["content"] || ""
410 end
411 end
412
413 def render_content(object), do: object.data["content"] || ""
414
415 @doc """
416 Builds a dictionary tags.
417
418 ## Examples
419
420 iex> Pleroma.Web.MastodonAPI.StatusView.build_tags(["fediverse", "nextcloud"])
421 [{"name": "fediverse", "url": "/tag/fediverse"},
422 {"name": "nextcloud", "url": "/tag/nextcloud"}]
423
424 """
425 @spec build_tags(list(any())) :: list(map())
426 def build_tags(object_tags) when is_list(object_tags) do
427 object_tags = for tag when is_binary(tag) <- object_tags, do: tag
428
429 Enum.reduce(object_tags, [], fn tag, tags ->
430 tags ++ [%{name: tag, url: "/tag/#{tag}"}]
431 end)
432 end
433
434 def build_tags(_), do: []
435
436 @doc """
437 Builds list emojis.
438
439 Arguments: `nil` or list tuple of name and url.
440
441 Returns list emojis.
442
443 ## Examples
444
445 iex> Pleroma.Web.MastodonAPI.StatusView.build_emojis([{"2hu", "corndog.png"}])
446 [%{shortcode: "2hu", static_url: "corndog.png", url: "corndog.png", visible_in_picker: false}]
447
448 """
449 @spec build_emojis(nil | list(tuple())) :: list(map())
450 def build_emojis(nil), do: []
451
452 def build_emojis(emojis) do
453 emojis
454 |> Enum.map(fn {name, url} ->
455 name = HTML.strip_tags(name)
456
457 url =
458 url
459 |> HTML.strip_tags()
460 |> MediaProxy.url()
461
462 %{shortcode: name, url: url, static_url: url, visible_in_picker: false}
463 end)
464 end
465
466 defp present?(nil), do: false
467 defp present?(false), do: false
468 defp present?(_), do: true
469
470 defp pinned?(%Activity{id: id}, %User{info: %{pinned_activities: pinned_activities}}),
471 do: id in pinned_activities
472 end