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