utils.ex 14.1 KB
Newer Older
1
# Pleroma: A lightweight social networking server
Haelwenn's avatar
Haelwenn committed
2
# Copyright © 2017-2021 Pleroma Authors <https://pleroma.social/>
3 4
# SPDX-License-Identifier: AGPL-3.0-only

5
defmodule Pleroma.Web.CommonAPI.Utils do
6 7
  import Pleroma.Web.Gettext

Maksim's avatar
Maksim committed
8
  alias Calendar.Strftime
Haelwenn's avatar
Haelwenn committed
9
  alias Pleroma.Activity
10
  alias Pleroma.Config
11
  alias Pleroma.Conversation.Participation
Haelwenn's avatar
Haelwenn committed
12 13 14
  alias Pleroma.Formatter
  alias Pleroma.Object
  alias Pleroma.Repo
Maksim's avatar
Maksim committed
15
  alias Pleroma.User
lain's avatar
lain committed
16
  alias Pleroma.Web.ActivityPub.Utils
17
  alias Pleroma.Web.ActivityPub.Visibility
minibikini's avatar
minibikini committed
18
  alias Pleroma.Web.CommonAPI.ActivityDraft
19
  alias Pleroma.Web.MediaProxy
20
  alias Pleroma.Web.Plugs.AuthenticationPlug
21
  alias Pleroma.Web.Utils.Params
lain's avatar
lain committed
22

23
  require Logger
24
  require Pleroma.Constants
lain's avatar
lain committed
25

26
  def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
Maksim's avatar
Maksim committed
27 28 29
    attachments_from_ids_descs(ids, desc)
  end

30
  def attachments_from_ids(%{media_ids: ids}) do
Maksim's avatar
Maksim committed
31
    attachments_from_ids_no_descs(ids)
32 33
  end

Maksim's avatar
Maksim committed
34 35 36 37
  def attachments_from_ids(_), do: []

  def attachments_from_ids_no_descs([]), do: []

38
  def attachments_from_ids_no_descs(ids) do
Maksim's avatar
Maksim committed
39 40
    Enum.map(ids, fn media_id ->
      case Repo.get(Object, media_id) do
41
        %Object{data: data} -> data
Maksim's avatar
Maksim committed
42 43
        _ -> nil
      end
lain's avatar
lain committed
44
    end)
45
    |> Enum.reject(&is_nil/1)
lain's avatar
lain committed
46 47
  end

Maksim's avatar
Maksim committed
48 49
  def attachments_from_ids_descs([], _), do: []

50
  def attachments_from_ids_descs(ids, descs_str) do
51 52
    {_, descs} = Jason.decode(descs_str)

Maksim's avatar
Maksim committed
53
    Enum.map(ids, fn media_id ->
minibikini's avatar
minibikini committed
54 55
      with %Object{data: data} <- Repo.get(Object, media_id) do
        Map.put(data, "name", descs[media_id])
Maksim's avatar
Maksim committed
56
      end
lain's avatar
lain committed
57
    end)
58
    |> Enum.reject(&is_nil/1)
lain's avatar
lain committed
59 60
  end

minibikini's avatar
minibikini committed
61
  @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
62

minibikini's avatar
minibikini committed
63
  def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
64 65 66 67
    participation = Repo.preload(participation, :recipients)
    {Enum.map(participation.recipients, & &1.ap_id), []}
  end

minibikini's avatar
minibikini committed
68 69 70 71
  def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
    to =
      case visibility do
        "public" -> [Pleroma.Constants.as_public() | draft.mentions]
72
        "local" -> [Utils.as_local_public() | draft.mentions]
minibikini's avatar
minibikini committed
73 74
      end

minibikini's avatar
minibikini committed
75
    cc = [draft.user.follower_address]
lain's avatar
lain committed
76

minibikini's avatar
minibikini committed
77 78
    if draft.in_reply_to do
      {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
lain's avatar
lain committed
79
    else
80 81 82 83
      {to, cc}
    end
  end

minibikini's avatar
minibikini committed
84 85
  def get_to_and_cc(%{visibility: "unlisted"} = draft) do
    to = [draft.user.follower_address | draft.mentions]
minibikini's avatar
minibikini committed
86
    cc = [Pleroma.Constants.as_public()]
87

minibikini's avatar
minibikini committed
88 89
    if draft.in_reply_to do
      {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
90 91 92
    else
      {to, cc}
    end
93 94
  end

minibikini's avatar
minibikini committed
95 96 97
  def get_to_and_cc(%{visibility: "private"} = draft) do
    {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
    {[draft.user.follower_address | to], cc}
98 99
  end

minibikini's avatar
minibikini committed
100
  def get_to_and_cc(%{visibility: "direct"} = draft) do
101
    # If the OP is a DM already, add the implicit actor.
minibikini's avatar
minibikini committed
102 103
    if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
      {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
104
    else
minibikini's avatar
minibikini committed
105
      {draft.mentions, []}
lain's avatar
lain committed
106 107 108
    end
  end

minibikini's avatar
minibikini committed
109 110
  def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}

lain's avatar
lain committed
111 112 113 114 115
  def get_addressed_users(_, to) when is_list(to) do
    User.get_ap_ids_by_nicknames(to)
  end

  def get_addressed_users(mentioned_users, _), do: mentioned_users
minibikini's avatar
minibikini committed
116

117
  def maybe_add_list_data(activity_params, user, {:list, list_id}) do
minibikini's avatar
minibikini committed
118 119
    case Pleroma.List.get(list_id, user) do
      %Pleroma.List{} = list ->
120 121 122 123
        activity_params
        |> put_in([:additional, "bcc"], [list.ap_id])
        |> put_in([:additional, "listMessage"], list.ap_id)
        |> put_in([:object, "listMessage"], list.ap_id)
minibikini's avatar
minibikini committed
124 125

      _ ->
126
        activity_params
minibikini's avatar
minibikini committed
127
    end
minibikini's avatar
minibikini committed
128 129
  end

130
  def maybe_add_list_data(activity_params, _, _), do: activity_params
minibikini's avatar
minibikini committed
131

minibikini's avatar
minibikini committed
132 133 134 135 136 137 138 139
  def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
      when is_binary(expires_in) do
    # In some cases mastofe sends out strings instead of integers
    data
    |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
    |> make_poll_data()
  end

140
  def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
141
      when is_list(options) do
feld's avatar
feld committed
142
    limits = Config.get([:instance, :poll_limits])
143

minibikini's avatar
minibikini committed
144 145 146 147
    with :ok <- validate_poll_expiration(expires_in, limits),
         :ok <- validate_poll_options_amount(options, limits),
         :ok <- validate_poll_options_length(options, limits) do
      {option_notes, emoji} =
148
        Enum.map_reduce(options, %{}, fn option, emoji ->
minibikini's avatar
minibikini committed
149 150 151 152 153
          note = %{
            "name" => option,
            "type" => "Note",
            "replies" => %{"type" => "Collection", "totalItems" => 0}
          }
154

155
          {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
minibikini's avatar
minibikini committed
156
        end)
157

158
      end_time =
159 160 161
        DateTime.utc_now()
        |> DateTime.add(expires_in)
        |> DateTime.to_iso8601()
162

163
      key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
minibikini's avatar
minibikini committed
164
      poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
165

minibikini's avatar
minibikini committed
166
      {:ok, {poll, emoji}}
167 168 169
    end
  end

rinpatch's avatar
rinpatch committed
170
  def make_poll_data(%{"poll" => poll}) when is_map(poll) do
minibikini's avatar
minibikini committed
171
    {:error, "Invalid poll"}
172 173
  end

174
  def make_poll_data(_data) do
minibikini's avatar
minibikini committed
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
    {:ok, {%{}, %{}}}
  end

  defp validate_poll_options_amount(options, %{max_options: max_options}) do
    if Enum.count(options) > max_options do
      {:error, "Poll can't contain more than #{max_options} options"}
    else
      :ok
    end
  end

  defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
    if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
      {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
    else
      :ok
    end
  end

  defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
    cond do
      expires_in > max -> {:error, "Expiration date is too far in the future"}
      expires_in < min -> {:error, "Expiration date is too soon"}
      true -> :ok
    end
200 201
  end

minibikini's avatar
minibikini committed
202
  def make_content_html(%ActivityDraft{} = draft) do
203
    attachment_links =
minibikini's avatar
minibikini committed
204
      draft.params
205
      |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
206
      |> Params.truthy_param?()
minibikini's avatar
minibikini committed
207

minibikini's avatar
minibikini committed
208
    content_type = get_content_type(draft.params[:content_type])
minibikini's avatar
minibikini committed
209

lain's avatar
lain committed
210
    options =
minibikini's avatar
minibikini committed
211
      if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
lain's avatar
lain committed
212 213 214 215 216
        [safe_mention: true]
      else
        []
      end

minibikini's avatar
minibikini committed
217
    draft.status
lain's avatar
lain committed
218
    |> format_input(content_type, options)
minibikini's avatar
minibikini committed
219
    |> maybe_add_attachments(draft.attachments, attachment_links)
minibikini's avatar
minibikini committed
220 221 222 223 224 225 226 227
  end

  defp get_content_type(content_type) do
    if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
      content_type
    else
      "text/plain"
    end
228 229
  end

230 231 232 233 234 235
  def make_context(_, %Participation{} = participation) do
    Repo.preload(participation, :conversation).conversation.ap_id
  end

  def make_context(%Activity{data: %{"context" => context}}, _), do: context
  def make_context(_, _), do: Utils.generate_context_id()
236

237
  def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
lain's avatar
lain committed
238

minibikini's avatar
minibikini committed
239 240 241
  def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
    text = add_attachments(text, attachments)
    {text, mentions, tags}
eal's avatar
eal committed
242
  end
lain's avatar
lain committed
243

lain's avatar
lain committed
244
  def add_attachments(text, attachments) do
Maksim's avatar
Maksim committed
245
    attachment_text = Enum.map(attachments, &build_attachment_link/1)
eal's avatar
eal committed
246
    Enum.join([text | attachment_text], "<br>")
lain's avatar
lain committed
247 248
  end

Maksim's avatar
Maksim committed
249 250 251 252 253 254 255 256
  defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
    name = attachment["name"] || URI.decode(Path.basename(href))
    href = MediaProxy.url(href)
    "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
  end

  defp build_attachment_link(_), do: ""

minibikini's avatar
minibikini committed
257
  def format_input(text, format, options \\ [])
258

Maksim's avatar
Maksim committed
259
  @doc """
feld's avatar
feld committed
260
  Formatting text to plain text, BBCode, HTML, or Markdown
Maksim's avatar
Maksim committed
261
  """
minibikini's avatar
minibikini committed
262
  def format_input(text, "text/plain", options) do
eal's avatar
eal committed
263
    text
264
    |> Formatter.html_escape("text/plain")
minibikini's avatar
minibikini committed
265 266 267 268
    |> Formatter.linkify(options)
    |> (fn {text, mentions, tags} ->
          {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
        end).()
lain's avatar
lain committed
269 270
  end

kaniini's avatar
kaniini committed
271 272 273 274 275 276 277 278 279
  def format_input(text, "text/bbcode", options) do
    text
    |> String.replace(~r/\r/, "")
    |> Formatter.html_escape("text/plain")
    |> BBCode.to_html()
    |> (fn {:ok, html} -> html end).()
    |> Formatter.linkify(options)
  end

minibikini's avatar
minibikini committed
280
  def format_input(text, "text/html", options) do
281 282
    text
    |> Formatter.html_escape("text/html")
minibikini's avatar
minibikini committed
283
    |> Formatter.linkify(options)
284 285
  end

minibikini's avatar
minibikini committed
286
  def format_input(text, "text/markdown", options) do
287
    text
minibikini's avatar
minibikini committed
288
    |> Formatter.mentions_escape(options)
289
    |> Formatter.markdown_to_html()
minibikini's avatar
minibikini committed
290
    |> Formatter.linkify(options)
291
    |> Formatter.html_escape("text/html")
lain's avatar
lain committed
292 293
  end

dtluna's avatar
dtluna committed
294 295 296 297 298 299 300 301
  def format_naive_asctime(date) do
    date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
  end

  def format_asctime(date) do
    Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
  end

302 303
  def date_to_asctime(date) when is_binary(date) do
    with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
dtluna's avatar
dtluna committed
304
      format_asctime(date)
lain's avatar
lain committed
305 306
    else
      _e ->
307
        Logger.warn("Date #{date} in wrong format, must be ISO 8601")
dtluna's avatar
dtluna committed
308 309 310
        ""
    end
  end
311

dtluna's avatar
dtluna committed
312
  def date_to_asctime(date) do
313 314
    Logger.warn("Date #{date} in wrong format, must be ISO 8601")
    ""
dtluna's avatar
dtluna committed
315
  end
316

317 318
  def to_masto_date(%NaiveDateTime{} = date) do
    date
lain's avatar
lain committed
319
    |> NaiveDateTime.to_iso8601()
320 321 322
    |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
  end

Maksim's avatar
Maksim committed
323 324 325 326 327
  def to_masto_date(date) when is_binary(date) do
    with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
      to_masto_date(date)
    else
      _ -> ""
328 329 330
    end
  end

Maksim's avatar
Maksim committed
331 332
  def to_masto_date(_), do: ""

333
  defp shortname(name) do
334
    with max_length when max_length > 0 <-
335
           Config.get([Pleroma.Upload, :filename_display_max_length], 30),
336 337
         true <- String.length(name) > max_length do
      String.slice(name, 0..max_length) <> "…"
338
    else
339
      _ -> name
340 341
    end
  end
342

343
  @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
344
  def confirm_current_password(user, password) do
minibikini's avatar
minibikini committed
345
    with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
346
         true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
347 348
      {:ok, db_user}
    else
349
      _ -> {:error, dgettext("errors", "Invalid password.")}
350 351
    end
  end
352

353 354 355 356 357 358 359
  def maybe_notify_to_recipients(
        recipients,
        %Activity{data: %{"to" => to, "type" => _type}} = _activity
      ) do
    recipients ++ to
  end

360 361
  def maybe_notify_to_recipients(recipients, _), do: recipients

362 363
  def maybe_notify_mentioned_recipients(
        recipients,
364
        %Activity{data: %{"to" => _to, "type" => type} = data} = activity
365 366
      )
      when type == "Create" do
367
    object = Object.normalize(activity, fetch: false)
368 369 370

    object_data =
      cond do
Maksim's avatar
Maksim committed
371
        not is_nil(object) ->
372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387
          object.data

        is_map(data["object"]) ->
          data["object"]

        true ->
          %{}
      end

    tagged_mentions = maybe_extract_mentions(object_data)

    recipients ++ tagged_mentions
  end

  def maybe_notify_mentioned_recipients(recipients, _), do: recipients

feld's avatar
feld committed
388 389 390
  # Do not notify subscribers if author is making a reply
  def maybe_notify_subscribers(recipients, %Activity{
        object: %Object{data: %{"inReplyTo" => _ap_id}}
391
      }) do
feld's avatar
feld committed
392
    recipients
393 394
  end

Sadposter's avatar
Sadposter committed
395
  def maybe_notify_subscribers(
396
        recipients,
397
        %Activity{data: %{"actor" => actor, "type" => type}} = activity
398 399
      )
      when type == "Create" do
400
    with %User{} = user <- User.get_cached_by_ap_id(actor) do
Sadposter's avatar
Sadposter committed
401 402
      subscriber_ids =
        user
403
        |> User.subscriber_users()
404
        |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
Sadposter's avatar
Sadposter committed
405 406 407
        |> Enum.map(& &1.ap_id)

      recipients ++ subscriber_ids
408 409
    else
      _e -> recipients
Sadposter's avatar
Sadposter committed
410 411 412 413 414
    end
  end

  def maybe_notify_subscribers(recipients, _), do: recipients

415 416 417 418 419 420
  def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
    with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
      user
      |> User.get_followers()
      |> Enum.map(& &1.ap_id)
      |> Enum.concat(recipients)
421 422
    else
      _e -> recipients
423 424 425 426 427
    end
  end

  def maybe_notify_followers(recipients, _), do: recipients

428 429
  def maybe_extract_mentions(%{"tag" => tag}) do
    tag
Maksim's avatar
Maksim committed
430
    |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
431
    |> Enum.map(fn x -> x["href"] end)
Maksim's avatar
Maksim committed
432
    |> Enum.uniq()
433 434 435
  end

  def maybe_extract_mentions(_), do: []
minibikini's avatar
Reports  
minibikini committed
436

minibikini's avatar
minibikini committed
437
  def make_report_content_html(nil), do: {:ok, {nil, [], []}}
minibikini's avatar
Reports  
minibikini committed
438 439

  def make_report_content_html(comment) do
feld's avatar
feld committed
440
    max_size = Config.get([:instance, :max_report_comment_size], 1000)
minibikini's avatar
Reports  
minibikini committed
441 442

    if String.length(comment) <= max_size do
minibikini's avatar
minibikini committed
443
      {:ok, format_input(comment, "text/plain")}
minibikini's avatar
Reports  
minibikini committed
444
    else
445 446
      {:error,
       dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
minibikini's avatar
Reports  
minibikini committed
447 448 449
    end
  end

450 451
  def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
      when is_list(status_ids) do
minibikini's avatar
Reports  
minibikini committed
452 453 454 455
    {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
  end

  def get_report_statuses(_, _), do: {:ok, nil}
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481

  # DEPRECATED mostly, context objects are now created at insertion time.
  def context_to_conversation_id(context) do
    with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
      id
    else
      _e ->
        changeset = Object.context_mapping(context)

        case Repo.insert(changeset) do
          {:ok, %{id: id}} ->
            id

          # This should be solved by an upsert, but it seems ecto
          # has problems accessing the constraint inside the jsonb.
          {:error, _} ->
            Object.get_cached_by_ap_id(context).id
        end
    end
  end

  def conversation_id_to_context(id) do
    with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
      context
    else
      _e ->
482
        {:error, dgettext("errors", "No such conversation")}
483 484
    end
  end
rinpatch's avatar
rinpatch committed
485

minibikini's avatar
minibikini committed
486 487 488 489 490
  def validate_character_limit("" = _full_payload, [] = _attachments) do
    {:error, dgettext("errors", "Cannot post an empty status without attachments")}
  end

  def validate_character_limit(full_payload, _attachments) do
feld's avatar
feld committed
491
    limit = Config.get([:instance, :limit])
492 493
    length = String.length(full_payload)

feld's avatar
feld committed
494
    if length <= limit do
minibikini's avatar
minibikini committed
495
      :ok
496
    else
497
      {:error, dgettext("errors", "The status is over the character limit")}
498 499
    end
  end
lain's avatar
lain committed
500
end