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