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