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