Merge branch 'fix-remote-follow-from-ostatus-subscribe' 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 # Do not notify subscribers if author is making a reply
443 def maybe_notify_subscribers(recipients, %Activity{
444 object: %Object{data: %{"inReplyTo" => _ap_id}}
445 }) do
446 recipients
447 end
448
449 def maybe_notify_subscribers(
450 recipients,
451 %Activity{data: %{"actor" => actor, "type" => type}} = activity
452 )
453 when type == "Create" do
454 with %User{} = user <- User.get_cached_by_ap_id(actor) do
455 subscriber_ids =
456 user
457 |> User.subscribers()
458 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
459 |> Enum.map(& &1.ap_id)
460
461 recipients ++ subscriber_ids
462 end
463 end
464
465 def maybe_notify_subscribers(recipients, _), do: recipients
466
467 def maybe_extract_mentions(%{"tag" => tag}) do
468 tag
469 |> Enum.filter(fn x -> is_map(x) end)
470 |> Enum.filter(fn x -> x["type"] == "Mention" end)
471 |> Enum.map(fn x -> x["href"] end)
472 end
473
474 def maybe_extract_mentions(_), do: []
475
476 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
477
478 def make_report_content_html(comment) do
479 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
480
481 if String.length(comment) <= max_size do
482 {:ok, format_input(comment, "text/plain")}
483 else
484 {:error,
485 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
486 end
487 end
488
489 def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do
490 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
491 end
492
493 def get_report_statuses(_, _), do: {:ok, nil}
494
495 # DEPRECATED mostly, context objects are now created at insertion time.
496 def context_to_conversation_id(context) do
497 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
498 id
499 else
500 _e ->
501 changeset = Object.context_mapping(context)
502
503 case Repo.insert(changeset) do
504 {:ok, %{id: id}} ->
505 id
506
507 # This should be solved by an upsert, but it seems ecto
508 # has problems accessing the constraint inside the jsonb.
509 {:error, _} ->
510 Object.get_cached_by_ap_id(context).id
511 end
512 end
513 end
514
515 def conversation_id_to_context(id) do
516 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
517 context
518 else
519 _e ->
520 {:error, dgettext("errors", "No such conversation")}
521 end
522 end
523
524 def make_answer_data(%User{ap_id: ap_id}, object, name) do
525 %{
526 "type" => "Answer",
527 "actor" => ap_id,
528 "cc" => [object.data["actor"]],
529 "to" => [],
530 "name" => name,
531 "inReplyTo" => object.data["id"]
532 }
533 end
534
535 def validate_character_limit(full_payload, attachments, limit) do
536 length = String.length(full_payload)
537
538 if length < limit do
539 if length > 0 or Enum.count(attachments) > 0 do
540 :ok
541 else
542 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
543 end
544 else
545 {:error, dgettext("errors", "The status is over the character limit")}
546 end
547 end
548 end