utils.ex 10.8 KB
Newer Older
1
# Pleroma: A lightweight social networking server
2
# Copyright © 2017-2019 Pleroma Authors <https://pleroma.social/>
3
4
# SPDX-License-Identifier: AGPL-3.0-only

5
defmodule Pleroma.Web.CommonAPI.Utils do
Maksim's avatar
Maksim committed
6
7
  alias Calendar.Strftime
  alias Comeonin.Pbkdf2
Haelwenn's avatar
Haelwenn committed
8
  alias Pleroma.Activity
9
  alias Pleroma.Config
Haelwenn's avatar
Haelwenn committed
10
11
12
  alias Pleroma.Formatter
  alias Pleroma.Object
  alias Pleroma.Repo
Maksim's avatar
Maksim committed
13
  alias Pleroma.User
lain's avatar
lain committed
14
  alias Pleroma.Web.ActivityPub.Utils
15
  alias Pleroma.Web.ActivityPub.Visibility
16
  alias Pleroma.Web.Endpoint
17
  alias Pleroma.Web.MediaProxy
lain's avatar
lain committed
18

19
  require Logger
lain's avatar
lain committed
20

21
22
  # This is a hack for twidere.
  def get_by_id_or_ap_id(id) do
kaniini's avatar
kaniini committed
23
24
    activity =
      Activity.get_by_id_with_object(id) || Activity.get_create_by_object_ap_id_with_object(id)
lain's avatar
lain committed
25

26
27
28
29
    activity &&
      if activity.data["type"] == "Create" do
        activity
      else
30
        Activity.get_create_by_object_ap_id_with_object(activity.data["object"])
31
      end
32
33
  end

lain's avatar
lain committed
34
35
  def get_replied_to_activity(""), do: nil

36
  def get_replied_to_activity(id) when not is_nil(id) do
37
    Activity.get_by_id(id)
38
  end
lain's avatar
lain committed
39

40
41
  def get_replied_to_activity(_), do: nil

42
43
44
45
46
47
48
49
50
  def attachments_from_ids(data) do
    if Map.has_key?(data, "descriptions") do
      attachments_from_ids_descs(data["media_ids"], data["descriptions"])
    else
      attachments_from_ids_no_descs(data["media_ids"])
    end
  end

  def attachments_from_ids_no_descs(ids) do
lain's avatar
lain committed
51
    Enum.map(ids || [], fn media_id ->
lain's avatar
lain committed
52
53
54
55
      Repo.get(Object, media_id).data
    end)
  end

56
  def attachments_from_ids_descs(ids, descs_str) do
57
58
59
60
    {_, descs} = Jason.decode(descs_str)

    Enum.map(ids || [], fn media_id ->
      Map.put(Repo.get(Object, media_id).data, "name", descs[media_id])
lain's avatar
lain committed
61
62
63
    end)
  end

64
  def to_for_user_and_mentions(user, mentions, inReplyTo, "public") do
lain's avatar
lain committed
65
    mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)
66
67
68

    to = ["https://www.w3.org/ns/activitystreams#Public" | mentioned_users]
    cc = [user.follower_address]
lain's avatar
lain committed
69

70
    if inReplyTo do
71
      {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
lain's avatar
lain committed
72
    else
73
74
75
76
77
      {to, cc}
    end
  end

  def to_for_user_and_mentions(user, mentions, inReplyTo, "unlisted") do
78
79
80
81
82
83
84
85
86
87
    mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)

    to = [user.follower_address | mentioned_users]
    cc = ["https://www.w3.org/ns/activitystreams#Public"]

    if inReplyTo do
      {Enum.uniq([inReplyTo.data["actor"] | to]), cc}
    else
      {to, cc}
    end
88
89
90
91
92
93
94
  end

  def to_for_user_and_mentions(user, mentions, inReplyTo, "private") do
    {to, cc} = to_for_user_and_mentions(user, mentions, inReplyTo, "direct")
    {[user.follower_address | to], cc}
  end

feld's avatar
feld committed
95
  def to_for_user_and_mentions(_user, mentions, inReplyTo, "direct") do
lain's avatar
lain committed
96
97
    mentioned_users = Enum.map(mentions, fn {_, %{ap_id: ap_id}} -> ap_id end)

98
99
100
101
    if inReplyTo do
      {Enum.uniq([inReplyTo.data["actor"] | mentioned_users]), []}
    else
      {mentioned_users, []}
lain's avatar
lain committed
102
103
104
    end
  end

105
106
107
  def make_content_html(
        status,
        attachments,
lain's avatar
lain committed
108
109
        data,
        visibility
110
      ) do
minibikini's avatar
minibikini committed
111
112
113
114
115
116
117
    no_attachment_links =
      data
      |> Map.get("no_attachment_links", Config.get([:instance, :no_attachment_links]))
      |> Kernel.in([true, "true"])

    content_type = get_content_type(data["content_type"])

lain's avatar
lain committed
118
119
120
121
122
123
124
    options =
      if visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
        [safe_mention: true]
      else
        []
      end

125
    status
lain's avatar
lain committed
126
    |> format_input(content_type, options)
eal's avatar
eal committed
127
    |> maybe_add_attachments(attachments, no_attachment_links)
minibikini's avatar
minibikini committed
128
129
130
131
132
133
134
135
136
    |> maybe_add_nsfw_tag(data)
  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
137
138
  end

minibikini's avatar
minibikini committed
139
140
141
142
143
144
145
  defp maybe_add_nsfw_tag({text, mentions, tags}, %{"sensitive" => sensitive})
       when sensitive in [true, "True", "true", "1"] do
    {text, mentions, [{"#nsfw", "nsfw"} | tags]}
  end

  defp maybe_add_nsfw_tag(data, _), do: data

146
  def make_context(%Activity{data: %{"context" => context}}), do: context
lain's avatar
lain committed
147
  def make_context(_), do: Utils.generate_context_id()
148

minibikini's avatar
minibikini committed
149
  def maybe_add_attachments(parsed, _attachments, true = _no_links), do: parsed
lain's avatar
lain committed
150

minibikini's avatar
minibikini committed
151
152
153
  def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
    text = add_attachments(text, attachments)
    {text, mentions, tags}
eal's avatar
eal committed
154
  end
lain's avatar
lain committed
155

lain's avatar
lain committed
156
  def add_attachments(text, attachments) do
lain's avatar
lain committed
157
158
    attachment_text =
      Enum.map(attachments, fn
159
160
        %{"url" => [%{"href" => href} | _]} = attachment ->
          name = attachment["name"] || URI.decode(Path.basename(href))
161
          href = MediaProxy.url(href)
lain's avatar
lain committed
162
163
164
165
166
167
          "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"

        _ ->
          ""
      end)

eal's avatar
eal committed
168
    Enum.join([text | attachment_text], "<br>")
lain's avatar
lain committed
169
170
  end

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

Maksim's avatar
Maksim committed
173
174
175
  @doc """
  Formatting text to plain text.
  """
minibikini's avatar
minibikini committed
176
  def format_input(text, "text/plain", options) do
eal's avatar
eal committed
177
    text
178
    |> Formatter.html_escape("text/plain")
minibikini's avatar
minibikini committed
179
180
181
182
    |> Formatter.linkify(options)
    |> (fn {text, mentions, tags} ->
          {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
        end).()
lain's avatar
lain committed
183
184
  end

kaniini's avatar
kaniini committed
185
186
187
188
189
190
191
192
193
194
195
196
  @doc """
  Formatting text as BBCode.
  """
  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

Maksim's avatar
Maksim committed
197
198
199
  @doc """
  Formatting text to html.
  """
minibikini's avatar
minibikini committed
200
  def format_input(text, "text/html", options) do
201
202
    text
    |> Formatter.html_escape("text/html")
minibikini's avatar
minibikini committed
203
    |> Formatter.linkify(options)
204
205
  end

Maksim's avatar
Maksim committed
206
207
208
  @doc """
  Formatting text to markdown.
  """
minibikini's avatar
minibikini committed
209
  def format_input(text, "text/markdown", options) do
210
    text
minibikini's avatar
minibikini committed
211
    |> Formatter.mentions_escape(options)
212
    |> Earmark.as_html!()
minibikini's avatar
minibikini committed
213
    |> Formatter.linkify(options)
214
    |> Formatter.html_escape("text/html")
lain's avatar
lain committed
215
216
  end

lain's avatar
lain committed
217
218
219
220
221
222
  def make_note_data(
        actor,
        to,
        context,
        content_html,
        attachments,
rinpatch's avatar
rinpatch committed
223
        in_reply_to,
lain's avatar
lain committed
224
225
226
227
228
229
230
231
232
233
234
235
236
        tags,
        cw \\ nil,
        cc \\ []
      ) do
    object = %{
      "type" => "Note",
      "to" => to,
      "cc" => cc,
      "content" => content_html,
      "summary" => cw,
      "context" => context,
      "attachment" => attachments,
      "actor" => actor,
237
      "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
lain's avatar
lain committed
238
    }
lain's avatar
lain committed
239

rinpatch's avatar
rinpatch committed
240
    if in_reply_to do
241
      in_reply_to_object = Object.normalize(in_reply_to)
242

lain's avatar
lain committed
243
      object
rinpatch's avatar
rinpatch committed
244
      |> Map.put("inReplyTo", in_reply_to_object.data["id"])
lain's avatar
lain committed
245
246
247
248
    else
      object
    end
  end
dtluna's avatar
dtluna committed
249
250
251
252
253
254
255
256
257

  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

258
259
  def date_to_asctime(date) when is_binary(date) do
    with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
dtluna's avatar
dtluna committed
260
      format_asctime(date)
lain's avatar
lain committed
261
262
    else
      _e ->
263
        Logger.warn("Date #{date} in wrong format, must be ISO 8601")
dtluna's avatar
dtluna committed
264
265
266
        ""
    end
  end
267

dtluna's avatar
dtluna committed
268
  def date_to_asctime(date) do
269
270
    Logger.warn("Date #{date} in wrong format, must be ISO 8601")
    ""
dtluna's avatar
dtluna committed
271
  end
272

273
274
  def to_masto_date(%NaiveDateTime{} = date) do
    date
lain's avatar
lain committed
275
    |> NaiveDateTime.to_iso8601()
276
277
278
279
280
281
    |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
  end

  def to_masto_date(date) do
    try do
      date
lain's avatar
lain committed
282
283
      |> NaiveDateTime.from_iso8601!()
      |> NaiveDateTime.to_iso8601()
284
285
286
287
288
289
      |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
    rescue
      _e -> ""
    end
  end

290
291
292
293
294
295
296
  defp shortname(name) do
    if String.length(name) < 30 do
      name
    else
      String.slice(name, 0..30) <> "…"
    end
  end
297

298
  def confirm_current_password(user, password) do
minibikini's avatar
minibikini committed
299
    with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
300
         true <- Pbkdf2.checkpw(password, db_user.password_hash) do
301
302
303
      {:ok, db_user}
    else
      _ -> {:error, "Invalid password."}
304
305
    end
  end
306

Maksim's avatar
Maksim committed
307
  def emoji_from_profile(%{info: _info} = user) do
308
    (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name))
309
    |> Enum.map(fn {shortcode, url, _} ->
310
311
      %{
        "type" => "Emoji",
eal's avatar
eal committed
312
        "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"},
313
314
315
316
        "name" => ":#{shortcode}:"
      }
    end)
  end
317
318
319
320
321
322
323
324
325
326

  def maybe_notify_to_recipients(
        recipients,
        %Activity{data: %{"to" => to, "type" => _type}} = _activity
      ) do
    recipients ++ to
  end

  def maybe_notify_mentioned_recipients(
        recipients,
327
        %Activity{data: %{"to" => _to, "type" => type} = data} = activity
328
329
      )
      when type == "Create" do
330
    object = Object.normalize(activity)
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350

    object_data =
      cond do
        !is_nil(object) ->
          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

Sadposter's avatar
Sadposter committed
351
  def maybe_notify_subscribers(
352
        recipients,
353
        %Activity{data: %{"actor" => actor, "type" => type}} = activity
354
355
      )
      when type == "Create" do
356
    with %User{} = user <- User.get_cached_by_ap_id(actor) do
Sadposter's avatar
Sadposter committed
357
358
      subscriber_ids =
        user
359
        |> User.subscribers()
360
        |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
Sadposter's avatar
Sadposter committed
361
362
363
364
365
366
367
368
        |> Enum.map(& &1.ap_id)

      recipients ++ subscriber_ids
    end
  end

  def maybe_notify_subscribers(recipients, _), do: recipients

369
370
371
372
373
374
375
376
  def maybe_extract_mentions(%{"tag" => tag}) do
    tag
    |> Enum.filter(fn x -> is_map(x) end)
    |> Enum.filter(fn x -> x["type"] == "Mention" end)
    |> Enum.map(fn x -> x["href"] end)
  end

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

minibikini's avatar
minibikini committed
378
  def make_report_content_html(nil), do: {:ok, {nil, [], []}}
minibikini's avatar
Reports    
minibikini committed
379
380
381
382
383

  def make_report_content_html(comment) do
    max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)

    if String.length(comment) <= max_size do
minibikini's avatar
minibikini committed
384
      {:ok, format_input(comment, "text/plain")}
minibikini's avatar
Reports    
minibikini committed
385
386
387
388
389
390
391
392
393
394
    else
      {:error, "Comment must be up to #{max_size} characters"}
    end
  end

  def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do
    {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
  end

  def get_report_statuses(_, _), do: {:ok, nil}
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423

  # 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 ->
        {:error, "No such conversation"}
    end
  end
lain's avatar
lain committed
424
end