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