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