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