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