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