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