Interpret `\n` as newline for MFM
[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.Utils.Params
21
22 require Logger
23 require Pleroma.Constants
24
25 def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
26 attachments_from_ids_descs(ids, desc)
27 end
28
29 def attachments_from_ids(%{media_ids: ids}) do
30 attachments_from_ids_no_descs(ids)
31 end
32
33 def attachments_from_ids(_), do: []
34
35 def attachments_from_ids_no_descs([]), do: []
36
37 def attachments_from_ids_no_descs(ids) do
38 Enum.map(ids, fn media_id ->
39 case get_attachment(media_id) do
40 %Object{data: data} -> data
41 _ -> nil
42 end
43 end)
44 |> Enum.reject(&is_nil/1)
45 end
46
47 def attachments_from_ids_descs([], _), do: []
48
49 def attachments_from_ids_descs(ids, descs_str) do
50 {_, descs} = Jason.decode(descs_str)
51
52 Enum.map(ids, fn media_id ->
53 with %Object{data: data} <- get_attachment(media_id) do
54 Map.put(data, "name", descs[media_id])
55 end
56 end)
57 |> Enum.reject(&is_nil/1)
58 end
59
60 defp get_attachment(media_id) do
61 Repo.get(Object, media_id)
62 end
63
64 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
65
66 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
67 participation = Repo.preload(participation, :recipients)
68 {Enum.map(participation.recipients, & &1.ap_id), []}
69 end
70
71 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
72 to =
73 case visibility do
74 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
75 "local" -> [Utils.as_local_public() | draft.mentions]
76 end
77
78 cc = [draft.user.follower_address]
79
80 if draft.in_reply_to do
81 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
82 else
83 {to, cc}
84 end
85 end
86
87 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
88 to = [draft.user.follower_address | draft.mentions]
89 cc = [Pleroma.Constants.as_public()]
90
91 if draft.in_reply_to do
92 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
93 else
94 {to, cc}
95 end
96 end
97
98 def get_to_and_cc(%{visibility: "private"} = draft) do
99 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
100 {[draft.user.follower_address | to], cc}
101 end
102
103 def get_to_and_cc(%{visibility: "direct"} = draft) do
104 # If the OP is a DM already, add the implicit actor.
105 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
106 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
107 else
108 {draft.mentions, []}
109 end
110 end
111
112 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
113
114 def get_addressed_users(_, to) when is_list(to) do
115 User.get_ap_ids_by_nicknames(to)
116 end
117
118 def get_addressed_users(mentioned_users, _), do: mentioned_users
119
120 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
121 case Pleroma.List.get(list_id, user) do
122 %Pleroma.List{} = list ->
123 activity_params
124 |> put_in([:additional, "bcc"], [list.ap_id])
125 |> put_in([:additional, "listMessage"], list.ap_id)
126 |> put_in([:object, "listMessage"], list.ap_id)
127
128 _ ->
129 activity_params
130 end
131 end
132
133 def maybe_add_list_data(activity_params, _, _), do: activity_params
134
135 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
136 when is_binary(expires_in) do
137 # In some cases mastofe sends out strings instead of integers
138 data
139 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
140 |> make_poll_data()
141 end
142
143 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
144 when is_list(options) do
145 limits = Config.get([:instance, :poll_limits])
146
147 with :ok <- validate_poll_expiration(expires_in, limits),
148 :ok <- validate_poll_options_amount(options, limits),
149 :ok <- validate_poll_options_length(options, limits) do
150 {option_notes, emoji} =
151 Enum.map_reduce(options, %{}, fn option, emoji ->
152 note = %{
153 "name" => option,
154 "type" => "Note",
155 "replies" => %{"type" => "Collection", "totalItems" => 0}
156 }
157
158 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
159 end)
160
161 end_time =
162 DateTime.utc_now()
163 |> DateTime.add(expires_in)
164 |> DateTime.to_iso8601()
165
166 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
167 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
168
169 {:ok, {poll, emoji}}
170 end
171 end
172
173 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
174 {:error, "Invalid poll"}
175 end
176
177 def make_poll_data(_data) do
178 {:ok, {%{}, %{}}}
179 end
180
181 defp validate_poll_options_amount(options, %{max_options: max_options}) do
182 if Enum.count(options) > max_options do
183 {:error, "Poll can't contain more than #{max_options} options"}
184 else
185 :ok
186 end
187 end
188
189 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
190 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
191 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
192 else
193 :ok
194 end
195 end
196
197 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
198 cond do
199 expires_in > max -> {:error, "Expiration date is too far in the future"}
200 expires_in < min -> {:error, "Expiration date is too soon"}
201 true -> :ok
202 end
203 end
204
205 def make_content_html(%ActivityDraft{} = draft) do
206 attachment_links =
207 draft.params
208 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
209 |> Params.truthy_param?()
210
211 content_type = get_content_type(draft.params[:content_type])
212
213 options =
214 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
215 [safe_mention: true]
216 else
217 []
218 end
219
220 draft.status
221 |> format_input(content_type, options)
222 |> maybe_add_attachments(draft.attachments, attachment_links)
223 end
224
225 def get_content_type(content_type) do
226 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
227 content_type
228 else
229 "text/plain"
230 end
231 end
232
233 def make_context(%{in_reply_to_conversation: %Participation{} = participation}) do
234 Repo.preload(participation, :conversation).conversation.ap_id
235 end
236
237 def make_context(%{in_reply_to: %Activity{data: %{"context" => context}}}), do: context
238 def make_context(%{quote: %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(%{breaks: true})
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 # NOTE: Elixir’s ISO 8601 format is a superset of the real standard
332 # It supports negative years for example.
333 # ISO8601 only supports years before 1583 with mutual agreement
334 if date.year < 1583 do
335 "1970-01-01T00:00:00Z"
336 else
337 date
338 |> NaiveDateTime.to_iso8601()
339 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
340 end
341 end
342
343 def to_masto_date(date) when is_binary(date) do
344 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
345 to_masto_date(date)
346 else
347 _ -> "1970-01-01T00:00:00Z"
348 end
349 end
350
351 def to_masto_date(_), do: "1970-01-01T00:00:00Z"
352
353 defp shortname(name) do
354 with max_length when max_length > 0 <-
355 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
356 true <- String.length(name) > max_length do
357 String.slice(name, 0..max_length) <> "…"
358 else
359 _ -> name
360 end
361 end
362
363 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
364 def confirm_current_password(user, password) do
365 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
366 true <- Pleroma.Password.checkpw(password, db_user.password_hash) do
367 {:ok, db_user}
368 else
369 _ -> {:error, dgettext("errors", "Invalid password.")}
370 end
371 end
372
373 def maybe_notify_to_recipients(
374 recipients,
375 %Activity{data: %{"to" => to, "type" => _type}} = _activity
376 ) do
377 recipients ++ to
378 end
379
380 def maybe_notify_to_recipients(recipients, _), do: recipients
381
382 def maybe_notify_mentioned_recipients(
383 recipients,
384 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
385 )
386 when type == "Create" do
387 object = Object.normalize(activity, fetch: false)
388
389 object_data =
390 cond do
391 not is_nil(object) ->
392 object.data
393
394 is_map(data["object"]) ->
395 data["object"]
396
397 true ->
398 %{}
399 end
400
401 tagged_mentions = maybe_extract_mentions(object_data)
402
403 recipients ++ tagged_mentions
404 end
405
406 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
407
408 def maybe_notify_subscribers(
409 recipients,
410 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
411 ) do
412 # Do not notify subscribers if author is making a reply
413 with %Object{data: object} <- Object.normalize(activity, fetch: false),
414 nil <- object["inReplyTo"],
415 %User{} = user <- User.get_cached_by_ap_id(actor) do
416 subscriber_ids =
417 user
418 |> User.subscriber_users()
419 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
420 |> Enum.map(& &1.ap_id)
421
422 recipients ++ subscriber_ids
423 else
424 _e -> recipients
425 end
426 end
427
428 def maybe_notify_subscribers(recipients, _), do: recipients
429
430 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
431 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
432 user
433 |> User.get_followers()
434 |> Enum.map(& &1.ap_id)
435 |> Enum.concat(recipients)
436 else
437 _e -> recipients
438 end
439 end
440
441 def maybe_notify_followers(recipients, _), do: recipients
442
443 def maybe_extract_mentions(%{"tag" => tag}) do
444 tag
445 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
446 |> Enum.map(fn x -> x["href"] end)
447 |> Enum.uniq()
448 end
449
450 def maybe_extract_mentions(_), do: []
451
452 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
453
454 def make_report_content_html(comment) do
455 max_size = Config.get([:instance, :max_report_comment_size], 1000)
456
457 if String.length(comment) <= max_size do
458 {:ok, format_input(comment, "text/plain")}
459 else
460 {:error,
461 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
462 end
463 end
464
465 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
466 when is_list(status_ids) do
467 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
468 end
469
470 def get_report_statuses(_, _), do: {:ok, nil}
471
472 def validate_character_limit("" = _full_payload, [] = _attachments) do
473 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
474 end
475
476 def validate_character_limit(full_payload, _attachments) do
477 limit = Config.get([:instance, :limit])
478 length = String.length(full_payload)
479
480 if length <= limit do
481 :ok
482 else
483 {:error, dgettext("errors", "The status is over the character limit")}
484 end
485 end
486 end