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