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