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