aee19a840de04aa2c1cc9e5d1e73a0a6299557f7
[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()
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 <- Pleroma.Password.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