Insert text representation of hashtags into object["hashtags"]
[akkoma] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2020 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.CommonAPI.Utils do
6 import Pleroma.Web.Gettext
7 import Pleroma.Web.ControllerHelper, only: [truthy_param?: 1]
8
9 alias Calendar.Strftime
10 alias Pleroma.Activity
11 alias Pleroma.Config
12 alias Pleroma.Conversation.Participation
13 alias Pleroma.Formatter
14 alias Pleroma.Object
15 alias Pleroma.Repo
16 alias Pleroma.User
17 alias Pleroma.Web.ActivityPub.Utils
18 alias Pleroma.Web.ActivityPub.Visibility
19 alias Pleroma.Web.CommonAPI.ActivityDraft
20 alias Pleroma.Web.MediaProxy
21 alias Pleroma.Web.Plugs.AuthenticationPlug
22
23 require Logger
24 require Pleroma.Constants
25
26 def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
27 attachments_from_ids_descs(ids, desc)
28 end
29
30 def attachments_from_ids(%{media_ids: ids}) do
31 attachments_from_ids_no_descs(ids)
32 end
33
34 def attachments_from_ids(_), do: []
35
36 def attachments_from_ids_no_descs([]), do: []
37
38 def attachments_from_ids_no_descs(ids) do
39 Enum.map(ids, fn media_id ->
40 case Repo.get(Object, media_id) do
41 %Object{data: data} -> data
42 _ -> nil
43 end
44 end)
45 |> Enum.reject(&is_nil/1)
46 end
47
48 def attachments_from_ids_descs([], _), do: []
49
50 def attachments_from_ids_descs(ids, descs_str) do
51 {_, descs} = Jason.decode(descs_str)
52
53 Enum.map(ids, fn media_id ->
54 with %Object{data: data} <- Repo.get(Object, media_id) do
55 Map.put(data, "name", descs[media_id])
56 end
57 end)
58 |> Enum.reject(&is_nil/1)
59 end
60
61 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
62
63 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
64 participation = Repo.preload(participation, :recipients)
65 {Enum.map(participation.recipients, & &1.ap_id), []}
66 end
67
68 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
69 to =
70 case visibility do
71 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
72 "local" -> [Pleroma.Constants.as_local_public() | draft.mentions]
73 end
74
75 cc = [draft.user.follower_address]
76
77 if draft.in_reply_to do
78 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
79 else
80 {to, cc}
81 end
82 end
83
84 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
85 to = [draft.user.follower_address | draft.mentions]
86 cc = [Pleroma.Constants.as_public()]
87
88 if draft.in_reply_to do
89 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
90 else
91 {to, cc}
92 end
93 end
94
95 def get_to_and_cc(%{visibility: "private"} = draft) do
96 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
97 {[draft.user.follower_address | to], cc}
98 end
99
100 def get_to_and_cc(%{visibility: "direct"} = draft) do
101 # If the OP is a DM already, add the implicit actor.
102 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
103 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
104 else
105 {draft.mentions, []}
106 end
107 end
108
109 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
110
111 def get_addressed_users(_, to) when is_list(to) do
112 User.get_ap_ids_by_nicknames(to)
113 end
114
115 def get_addressed_users(mentioned_users, _), do: mentioned_users
116
117 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
118 case Pleroma.List.get(list_id, user) do
119 %Pleroma.List{} = list ->
120 activity_params
121 |> put_in([:additional, "bcc"], [list.ap_id])
122 |> put_in([:additional, "listMessage"], list.ap_id)
123 |> put_in([:object, "listMessage"], list.ap_id)
124
125 _ ->
126 activity_params
127 end
128 end
129
130 def maybe_add_list_data(activity_params, _, _), do: activity_params
131
132 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
133 when is_binary(expires_in) do
134 # In some cases mastofe sends out strings instead of integers
135 data
136 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
137 |> make_poll_data()
138 end
139
140 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
141 when is_list(options) do
142 limits = Config.get([:instance, :poll_limits])
143
144 with :ok <- validate_poll_expiration(expires_in, limits),
145 :ok <- validate_poll_options_amount(options, limits),
146 :ok <- validate_poll_options_length(options, limits) do
147 {option_notes, emoji} =
148 Enum.map_reduce(options, %{}, fn option, emoji ->
149 note = %{
150 "name" => option,
151 "type" => "Note",
152 "replies" => %{"type" => "Collection", "totalItems" => 0}
153 }
154
155 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
156 end)
157
158 end_time =
159 DateTime.utc_now()
160 |> DateTime.add(expires_in)
161 |> DateTime.to_iso8601()
162
163 key = if truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
164 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
165
166 {:ok, {poll, emoji}}
167 end
168 end
169
170 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
171 {:error, "Invalid poll"}
172 end
173
174 def make_poll_data(_data) do
175 {:ok, {%{}, %{}}}
176 end
177
178 defp validate_poll_options_amount(options, %{max_options: max_options}) do
179 if Enum.count(options) > max_options do
180 {:error, "Poll can't contain more than #{max_options} options"}
181 else
182 :ok
183 end
184 end
185
186 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
187 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
188 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
189 else
190 :ok
191 end
192 end
193
194 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
195 cond do
196 expires_in > max -> {:error, "Expiration date is too far in the future"}
197 expires_in < min -> {:error, "Expiration date is too soon"}
198 true -> :ok
199 end
200 end
201
202 def make_content_html(%ActivityDraft{} = draft) do
203 attachment_links =
204 draft.params
205 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
206 |> truthy_param?()
207
208 content_type = get_content_type(draft.params[:content_type])
209
210 options =
211 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
212 [safe_mention: true]
213 else
214 []
215 end
216
217 draft.status
218 |> format_input(content_type, options)
219 |> maybe_add_attachments(draft.attachments, attachment_links)
220 |> maybe_add_nsfw_tag(draft.params)
221 end
222
223 defp get_content_type(content_type) do
224 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
225 content_type
226 else
227 "text/plain"
228 end
229 end
230
231 defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
232 when sensitive in [true, "True", "true", "1"] do
233 {text, mentions, [{"#nsfw", "nsfw"} | tags]}
234 end
235
236 defp maybe_add_nsfw_tag(data, _), do: data
237
238 def make_context(_, %Participation{} = participation) do
239 Repo.preload(participation, :conversation).conversation.ap_id
240 end
241
242 def make_context(%Activity{data: %{"context" => context}}, _), do: context
243 def make_context(_, _), do: Utils.generate_context_id()
244
245 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
246
247 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
248 text = add_attachments(text, attachments)
249 {text, mentions, tags}
250 end
251
252 def add_attachments(text, attachments) do
253 attachment_text = Enum.map(attachments, &build_attachment_link/1)
254 Enum.join([text | attachment_text], "<br>")
255 end
256
257 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
258 name = attachment["name"] || URI.decode(Path.basename(href))
259 href = MediaProxy.url(href)
260 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
261 end
262
263 defp build_attachment_link(_), do: ""
264
265 def format_input(text, format, options \\ [])
266
267 @doc """
268 Formatting text to plain text, BBCode, HTML, or Markdown
269 """
270 def format_input(text, "text/plain", options) do
271 text
272 |> Formatter.html_escape("text/plain")
273 |> Formatter.linkify(options)
274 |> (fn {text, mentions, tags} ->
275 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
276 end).()
277 end
278
279 def format_input(text, "text/bbcode", options) do
280 text
281 |> String.replace(~r/\r/, "")
282 |> Formatter.html_escape("text/plain")
283 |> BBCode.to_html()
284 |> (fn {:ok, html} -> html end).()
285 |> Formatter.linkify(options)
286 end
287
288 def format_input(text, "text/html", options) do
289 text
290 |> Formatter.html_escape("text/html")
291 |> Formatter.linkify(options)
292 end
293
294 def format_input(text, "text/markdown", options) do
295 text
296 |> Formatter.mentions_escape(options)
297 |> Earmark.as_html!(%Earmark.Options{renderer: Pleroma.EarmarkRenderer})
298 |> Formatter.linkify(options)
299 |> Formatter.html_escape("text/html")
300 end
301
302 def make_note_data(%ActivityDraft{} = draft) do
303 %{
304 "type" => "Note",
305 "to" => draft.to,
306 "cc" => draft.cc,
307 "content" => draft.content_html,
308 "summary" => draft.summary,
309 "sensitive" => draft.sensitive,
310 "context" => draft.context,
311 "attachment" => draft.attachments,
312 "actor" => draft.user.ap_id,
313 "tag" => Enum.filter(draft.tags, &is_map(&1)) |> Enum.uniq(),
314 "hashtags" =>
315 draft.tags
316 |> Enum.reduce([], fn
317 # Why so many formats
318 {:name, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc
319 {"#" <> _, x}, acc -> if is_bitstring(x), do: [x | acc], else: acc
320 x, acc -> if is_bitstring(x), do: [x | acc], else: acc
321 end)
322 |> Enum.uniq()
323 }
324 |> add_in_reply_to(draft.in_reply_to)
325 |> Map.merge(draft.extra)
326 end
327
328 defp add_in_reply_to(object, nil), do: object
329
330 defp add_in_reply_to(object, in_reply_to) do
331 with %Object{} = in_reply_to_object <- Object.normalize(in_reply_to) do
332 Map.put(object, "inReplyTo", in_reply_to_object.data["id"])
333 else
334 _ -> object
335 end
336 end
337
338 def format_naive_asctime(date) do
339 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
340 end
341
342 def format_asctime(date) do
343 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
344 end
345
346 def date_to_asctime(date) when is_binary(date) do
347 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
348 format_asctime(date)
349 else
350 _e ->
351 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
352 ""
353 end
354 end
355
356 def date_to_asctime(date) do
357 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
358 ""
359 end
360
361 def to_masto_date(%NaiveDateTime{} = date) do
362 date
363 |> NaiveDateTime.to_iso8601()
364 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
365 end
366
367 def to_masto_date(date) when is_binary(date) do
368 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
369 to_masto_date(date)
370 else
371 _ -> ""
372 end
373 end
374
375 def to_masto_date(_), do: ""
376
377 defp shortname(name) do
378 with max_length when max_length > 0 <-
379 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
380 true <- String.length(name) > max_length do
381 String.slice(name, 0..max_length) <> "…"
382 else
383 _ -> name
384 end
385 end
386
387 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
388 def confirm_current_password(user, password) do
389 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
390 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
391 {:ok, db_user}
392 else
393 _ -> {:error, dgettext("errors", "Invalid password.")}
394 end
395 end
396
397 def maybe_notify_to_recipients(
398 recipients,
399 %Activity{data: %{"to" => to, "type" => _type}} = _activity
400 ) do
401 recipients ++ to
402 end
403
404 def maybe_notify_to_recipients(recipients, _), do: recipients
405
406 def maybe_notify_mentioned_recipients(
407 recipients,
408 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
409 )
410 when type == "Create" do
411 object = Object.normalize(activity, false)
412
413 object_data =
414 cond do
415 not is_nil(object) ->
416 object.data
417
418 is_map(data["object"]) ->
419 data["object"]
420
421 true ->
422 %{}
423 end
424
425 tagged_mentions = maybe_extract_mentions(object_data)
426
427 recipients ++ tagged_mentions
428 end
429
430 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
431
432 # Do not notify subscribers if author is making a reply
433 def maybe_notify_subscribers(recipients, %Activity{
434 object: %Object{data: %{"inReplyTo" => _ap_id}}
435 }) do
436 recipients
437 end
438
439 def maybe_notify_subscribers(
440 recipients,
441 %Activity{data: %{"actor" => actor, "type" => type}} = activity
442 )
443 when type == "Create" do
444 with %User{} = user <- User.get_cached_by_ap_id(actor) do
445 subscriber_ids =
446 user
447 |> User.subscriber_users()
448 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
449 |> Enum.map(& &1.ap_id)
450
451 recipients ++ subscriber_ids
452 else
453 _e -> recipients
454 end
455 end
456
457 def maybe_notify_subscribers(recipients, _), do: recipients
458
459 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
460 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
461 user
462 |> User.get_followers()
463 |> Enum.map(& &1.ap_id)
464 |> Enum.concat(recipients)
465 else
466 _e -> recipients
467 end
468 end
469
470 def maybe_notify_followers(recipients, _), do: recipients
471
472 def maybe_extract_mentions(%{"tag" => tag}) do
473 tag
474 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
475 |> Enum.map(fn x -> x["href"] end)
476 |> Enum.uniq()
477 end
478
479 def maybe_extract_mentions(_), do: []
480
481 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
482
483 def make_report_content_html(comment) do
484 max_size = Config.get([:instance, :max_report_comment_size], 1000)
485
486 if String.length(comment) <= max_size do
487 {:ok, format_input(comment, "text/plain")}
488 else
489 {:error,
490 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
491 end
492 end
493
494 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
495 when is_list(status_ids) do
496 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
497 end
498
499 def get_report_statuses(_, _), do: {:ok, nil}
500
501 # DEPRECATED mostly, context objects are now created at insertion time.
502 def context_to_conversation_id(context) do
503 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
504 id
505 else
506 _e ->
507 changeset = Object.context_mapping(context)
508
509 case Repo.insert(changeset) do
510 {:ok, %{id: id}} ->
511 id
512
513 # This should be solved by an upsert, but it seems ecto
514 # has problems accessing the constraint inside the jsonb.
515 {:error, _} ->
516 Object.get_cached_by_ap_id(context).id
517 end
518 end
519 end
520
521 def conversation_id_to_context(id) do
522 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
523 context
524 else
525 _e ->
526 {:error, dgettext("errors", "No such conversation")}
527 end
528 end
529
530 def validate_character_limit("" = _full_payload, [] = _attachments) do
531 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
532 end
533
534 def validate_character_limit(full_payload, _attachments) do
535 limit = Config.get([:instance, :limit])
536 length = String.length(full_payload)
537
538 if length <= limit do
539 :ok
540 else
541 {:error, dgettext("errors", "The status is over the character limit")}
542 end
543 end
544 end