make 2fa UI less awful
[akkoma] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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
8 alias Calendar.Strftime
9 alias Pleroma.Activity
10 alias Pleroma.Config
11 alias Pleroma.Conversation.Participation
12 alias Pleroma.Formatter
13 alias Pleroma.Object
14 alias Pleroma.Repo
15 alias Pleroma.User
16 alias Pleroma.Web.ActivityPub.Utils
17 alias Pleroma.Web.ActivityPub.Visibility
18 alias Pleroma.Web.CommonAPI.ActivityDraft
19 alias Pleroma.Web.MediaProxy
20 alias Pleroma.Web.Plugs.AuthenticationPlug
21 alias Pleroma.Web.Utils.Params
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 get_attachment(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} <- get_attachment(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 defp get_attachment(media_id) do
62 Repo.get(Object, media_id)
63 end
64
65 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
66
67 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
68 participation = Repo.preload(participation, :recipients)
69 {Enum.map(participation.recipients, & &1.ap_id), []}
70 end
71
72 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
73 to =
74 case visibility do
75 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
76 "local" -> [Utils.as_local_public() | draft.mentions]
77 end
78
79 cc = [draft.user.follower_address]
80
81 if draft.in_reply_to do
82 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
83 else
84 {to, cc}
85 end
86 end
87
88 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
89 to = [draft.user.follower_address | draft.mentions]
90 cc = [Pleroma.Constants.as_public()]
91
92 if draft.in_reply_to do
93 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
94 else
95 {to, cc}
96 end
97 end
98
99 def get_to_and_cc(%{visibility: "private"} = draft) do
100 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
101 {[draft.user.follower_address | to], cc}
102 end
103
104 def get_to_and_cc(%{visibility: "direct"} = draft) do
105 # If the OP is a DM already, add the implicit actor.
106 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
107 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
108 else
109 {draft.mentions, []}
110 end
111 end
112
113 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
114
115 def get_addressed_users(_, to) when is_list(to) do
116 User.get_ap_ids_by_nicknames(to)
117 end
118
119 def get_addressed_users(mentioned_users, _), do: mentioned_users
120
121 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
122 case Pleroma.List.get(list_id, user) do
123 %Pleroma.List{} = list ->
124 activity_params
125 |> put_in([:additional, "bcc"], [list.ap_id])
126 |> put_in([:additional, "listMessage"], list.ap_id)
127 |> put_in([:object, "listMessage"], list.ap_id)
128
129 _ ->
130 activity_params
131 end
132 end
133
134 def maybe_add_list_data(activity_params, _, _), do: activity_params
135
136 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
137 when is_binary(expires_in) do
138 # In some cases mastofe sends out strings instead of integers
139 data
140 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
141 |> make_poll_data()
142 end
143
144 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
145 when is_list(options) do
146 limits = Config.get([:instance, :poll_limits])
147
148 with :ok <- validate_poll_expiration(expires_in, limits),
149 :ok <- validate_poll_options_amount(options, limits),
150 :ok <- validate_poll_options_length(options, limits) do
151 {option_notes, emoji} =
152 Enum.map_reduce(options, %{}, fn option, emoji ->
153 note = %{
154 "name" => option,
155 "type" => "Note",
156 "replies" => %{"type" => "Collection", "totalItems" => 0}
157 }
158
159 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
160 end)
161
162 end_time =
163 DateTime.utc_now()
164 |> DateTime.add(expires_in)
165 |> DateTime.to_iso8601()
166
167 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
168 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
169
170 {:ok, {poll, emoji}}
171 end
172 end
173
174 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
175 {:error, "Invalid poll"}
176 end
177
178 def make_poll_data(_data) do
179 {:ok, {%{}, %{}}}
180 end
181
182 defp validate_poll_options_amount(options, %{max_options: max_options}) do
183 if Enum.count(options) > max_options do
184 {:error, "Poll can't contain more than #{max_options} options"}
185 else
186 :ok
187 end
188 end
189
190 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
191 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
192 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
193 else
194 :ok
195 end
196 end
197
198 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
199 cond do
200 expires_in > max -> {:error, "Expiration date is too far in the future"}
201 expires_in < min -> {:error, "Expiration date is too soon"}
202 true -> :ok
203 end
204 end
205
206 def make_content_html(%ActivityDraft{} = draft) do
207 attachment_links =
208 draft.params
209 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
210 |> Params.truthy_param?()
211
212 content_type = get_content_type(draft.params[:content_type])
213
214 options =
215 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
216 [safe_mention: true]
217 else
218 []
219 end
220
221 draft.status
222 |> format_input(content_type, options)
223 |> maybe_add_attachments(draft.attachments, attachment_links)
224 end
225
226 def get_content_type(content_type) do
227 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
228 content_type
229 else
230 "text/plain"
231 end
232 end
233
234 def make_context(_, %Participation{} = participation) do
235 Repo.preload(participation, :conversation).conversation.ap_id
236 end
237
238 def make_context(%Activity{data: %{"context" => context}}, _), do: context
239 def make_context(_, _), do: Utils.generate_context_id()
240
241 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
242
243 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
244 text = add_attachments(text, attachments)
245 {text, mentions, tags}
246 end
247
248 def add_attachments(text, attachments) do
249 attachment_text = Enum.map(attachments, &build_attachment_link/1)
250 Enum.join([text | attachment_text], "<br>")
251 end
252
253 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
254 name = attachment["name"] || URI.decode(Path.basename(href))
255 href = MediaProxy.url(href)
256 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
257 end
258
259 defp build_attachment_link(_), do: ""
260
261 def format_input(text, format, options \\ [])
262
263 @doc """
264 Formatting text to plain text, BBCode, HTML, or Markdown
265 """
266 def format_input(text, "text/plain", options) do
267 text
268 |> Formatter.html_escape("text/plain")
269 |> Formatter.linkify(options)
270 |> (fn {text, mentions, tags} ->
271 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
272 end).()
273 end
274
275 def format_input(text, "text/bbcode", options) do
276 text
277 |> String.replace(~r/\r/, "")
278 |> Formatter.html_escape("text/plain")
279 |> BBCode.to_html()
280 |> (fn {:ok, html} -> html end).()
281 |> Formatter.linkify(options)
282 end
283
284 def format_input(text, "text/html", options) do
285 text
286 |> Formatter.html_escape("text/html")
287 |> Formatter.linkify(options)
288 end
289
290 def format_input(text, "text/x.misskeymarkdown", options) do
291 text
292 |> Formatter.markdown_to_html()
293 |> MfmParser.Parser.parse()
294 |> MfmParser.Encoder.to_html()
295 |> Formatter.linkify(options)
296 |> Formatter.html_escape("text/html")
297 end
298
299 def format_input(text, "text/markdown", options) do
300 text
301 |> Formatter.mentions_escape(options)
302 |> Formatter.markdown_to_html()
303 |> Formatter.linkify(options)
304 |> Formatter.html_escape("text/html")
305 end
306
307 def format_naive_asctime(date) do
308 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
309 end
310
311 def format_asctime(date) do
312 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
313 end
314
315 def date_to_asctime(date) when is_binary(date) do
316 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
317 format_asctime(date)
318 else
319 _e ->
320 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
321 ""
322 end
323 end
324
325 def date_to_asctime(date) do
326 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
327 ""
328 end
329
330 def to_masto_date(%NaiveDateTime{} = date) do
331 date
332 |> NaiveDateTime.to_iso8601()
333 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
334 end
335
336 def to_masto_date(date) when is_binary(date) do
337 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
338 to_masto_date(date)
339 else
340 _ -> ""
341 end
342 end
343
344 def to_masto_date(_), do: ""
345
346 defp shortname(name) do
347 with max_length when max_length > 0 <-
348 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
349 true <- String.length(name) > max_length do
350 String.slice(name, 0..max_length) <> "…"
351 else
352 _ -> name
353 end
354 end
355
356 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
357 def confirm_current_password(user, password) do
358 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
359 true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
360 {:ok, db_user}
361 else
362 _ -> {:error, dgettext("errors", "Invalid password.")}
363 end
364 end
365
366 def maybe_notify_to_recipients(
367 recipients,
368 %Activity{data: %{"to" => to, "type" => _type}} = _activity
369 ) do
370 recipients ++ to
371 end
372
373 def maybe_notify_to_recipients(recipients, _), do: recipients
374
375 def maybe_notify_mentioned_recipients(
376 recipients,
377 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
378 )
379 when type == "Create" do
380 object = Object.normalize(activity, fetch: false)
381
382 object_data =
383 cond do
384 not is_nil(object) ->
385 object.data
386
387 is_map(data["object"]) ->
388 data["object"]
389
390 true ->
391 %{}
392 end
393
394 tagged_mentions = maybe_extract_mentions(object_data)
395
396 recipients ++ tagged_mentions
397 end
398
399 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
400
401 def maybe_notify_subscribers(
402 recipients,
403 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
404 ) do
405 # Do not notify subscribers if author is making a reply
406 with %Object{data: object} <- Object.normalize(activity, fetch: false),
407 nil <- object["inReplyTo"],
408 %User{} = user <- User.get_cached_by_ap_id(actor) do
409 subscriber_ids =
410 user
411 |> User.subscriber_users()
412 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
413 |> Enum.map(& &1.ap_id)
414
415 recipients ++ subscriber_ids
416 else
417 _e -> recipients
418 end
419 end
420
421 def maybe_notify_subscribers(recipients, _), do: recipients
422
423 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
424 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
425 user
426 |> User.get_followers()
427 |> Enum.map(& &1.ap_id)
428 |> Enum.concat(recipients)
429 else
430 _e -> recipients
431 end
432 end
433
434 def maybe_notify_followers(recipients, _), do: recipients
435
436 def maybe_extract_mentions(%{"tag" => tag}) do
437 tag
438 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
439 |> Enum.map(fn x -> x["href"] end)
440 |> Enum.uniq()
441 end
442
443 def maybe_extract_mentions(_), do: []
444
445 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
446
447 def make_report_content_html(comment) do
448 max_size = Config.get([:instance, :max_report_comment_size], 1000)
449
450 if String.length(comment) <= max_size do
451 {:ok, format_input(comment, "text/plain")}
452 else
453 {:error,
454 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
455 end
456 end
457
458 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
459 when is_list(status_ids) do
460 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
461 end
462
463 def get_report_statuses(_, _), do: {:ok, nil}
464
465 def validate_character_limit("" = _full_payload, [] = _attachments) do
466 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
467 end
468
469 def validate_character_limit(full_payload, _attachments) do
470 limit = Config.get([:instance, :limit])
471 length = String.length(full_payload)
472
473 if length <= limit do
474 :ok
475 else
476 {:error, dgettext("errors", "The status is over the character limit")}
477 end
478 end
479 end