Rich media doesn't need to be a map
[akkoma] / lib / pleroma / web / common_api / utils.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2021 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.Conversation.Participation
12 alias Pleroma.Formatter
13 alias Pleroma.Object
14 alias Pleroma.Repo
15 alias Pleroma.User
16 alias Pleroma.Web.ActivityPub.Utils
17 alias Pleroma.Web.ActivityPub.Visibility
18 alias Pleroma.Web.CommonAPI.ActivityDraft
19 alias Pleroma.Web.MediaProxy
20 alias Pleroma.Web.Utils.Params
21
22 require Logger
23 require Pleroma.Constants
24
25 def attachments_from_ids(%{media_ids: ids, descriptions: desc}) do
26 attachments_from_ids_descs(ids, desc)
27 end
28
29 def attachments_from_ids(%{media_ids: ids}) do
30 attachments_from_ids_no_descs(ids)
31 end
32
33 def attachments_from_ids(_), do: []
34
35 def attachments_from_ids_no_descs([]), do: []
36
37 def attachments_from_ids_no_descs(ids) do
38 Enum.map(ids, fn media_id ->
39 case get_attachment(media_id) do
40 %Object{data: data} -> data
41 _ -> nil
42 end
43 end)
44 |> Enum.reject(&is_nil/1)
45 end
46
47 def attachments_from_ids_descs([], _), do: []
48
49 def attachments_from_ids_descs(ids, descs_str) do
50 {_, descs} = Jason.decode(descs_str)
51
52 Enum.map(ids, fn media_id ->
53 with %Object{data: data} <- get_attachment(media_id) do
54 Map.put(data, "name", descs[media_id])
55 end
56 end)
57 |> Enum.reject(&is_nil/1)
58 end
59
60 defp get_attachment(media_id) do
61 Repo.get(Object, media_id)
62 end
63
64 @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
65
66 def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
67 participation = Repo.preload(participation, :recipients)
68 {Enum.map(participation.recipients, & &1.ap_id), []}
69 end
70
71 def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
72 to =
73 case visibility do
74 "public" -> [Pleroma.Constants.as_public() | draft.mentions]
75 "local" -> [Utils.as_local_public() | draft.mentions]
76 end
77
78 cc = [draft.user.follower_address]
79
80 if draft.in_reply_to do
81 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
82 else
83 {to, cc}
84 end
85 end
86
87 def get_to_and_cc(%{visibility: "unlisted"} = draft) do
88 to = [draft.user.follower_address | draft.mentions]
89 cc = [Pleroma.Constants.as_public()]
90
91 if draft.in_reply_to do
92 {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
93 else
94 {to, cc}
95 end
96 end
97
98 def get_to_and_cc(%{visibility: "private"} = draft) do
99 {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
100 {[draft.user.follower_address | to], cc}
101 end
102
103 def get_to_and_cc(%{visibility: "direct"} = draft) do
104 # If the OP is a DM already, add the implicit actor.
105 if draft.in_reply_to && Visibility.is_direct?(draft.in_reply_to) do
106 {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
107 else
108 {draft.mentions, []}
109 end
110 end
111
112 def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
113
114 def get_addressed_users(_, to) when is_list(to) do
115 User.get_ap_ids_by_nicknames(to)
116 end
117
118 def get_addressed_users(mentioned_users, _), do: mentioned_users
119
120 def maybe_add_list_data(activity_params, user, {:list, list_id}) do
121 case Pleroma.List.get(list_id, user) do
122 %Pleroma.List{} = list ->
123 activity_params
124 |> put_in([:additional, "bcc"], [list.ap_id])
125 |> put_in([:additional, "listMessage"], list.ap_id)
126 |> put_in([:object, "listMessage"], list.ap_id)
127
128 _ ->
129 activity_params
130 end
131 end
132
133 def maybe_add_list_data(activity_params, _, _), do: activity_params
134
135 def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
136 when is_binary(expires_in) do
137 # In some cases mastofe sends out strings instead of integers
138 data
139 |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
140 |> make_poll_data()
141 end
142
143 def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
144 when is_list(options) do
145 limits = Config.get([:instance, :poll_limits])
146
147 with :ok <- validate_poll_expiration(expires_in, limits),
148 :ok <- validate_poll_options_amount(options, limits),
149 :ok <- validate_poll_options_length(options, limits) do
150 {option_notes, emoji} =
151 Enum.map_reduce(options, %{}, fn option, emoji ->
152 note = %{
153 "name" => option,
154 "type" => "Note",
155 "replies" => %{"type" => "Collection", "totalItems" => 0}
156 }
157
158 {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
159 end)
160
161 end_time =
162 DateTime.utc_now()
163 |> DateTime.add(expires_in)
164 |> DateTime.to_iso8601()
165
166 key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
167 poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
168
169 {:ok, {poll, emoji}}
170 end
171 end
172
173 def make_poll_data(%{"poll" => poll}) when is_map(poll) do
174 {:error, "Invalid poll"}
175 end
176
177 def make_poll_data(_data) do
178 {:ok, {%{}, %{}}}
179 end
180
181 defp validate_poll_options_amount(options, %{max_options: max_options}) do
182 if Enum.count(options) > max_options do
183 {:error, "Poll can't contain more than #{max_options} options"}
184 else
185 :ok
186 end
187 end
188
189 defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
190 if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
191 {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
192 else
193 :ok
194 end
195 end
196
197 defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
198 cond do
199 expires_in > max -> {:error, "Expiration date is too far in the future"}
200 expires_in < min -> {:error, "Expiration date is too soon"}
201 true -> :ok
202 end
203 end
204
205 def make_content_html(%ActivityDraft{} = draft) do
206 attachment_links =
207 draft.params
208 |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
209 |> Params.truthy_param?()
210
211 content_type = get_content_type(draft.params[:content_type])
212
213 options =
214 if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
215 [safe_mention: true]
216 else
217 []
218 end
219
220 draft.status
221 |> format_input(content_type, options)
222 |> maybe_add_attachments(draft.attachments, attachment_links)
223 end
224
225 def get_content_type(content_type) do
226 if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
227 content_type
228 else
229 "text/plain"
230 end
231 end
232
233 def make_context(_, %Participation{} = participation) do
234 Repo.preload(participation, :conversation).conversation.ap_id
235 end
236
237 def make_context(%Activity{data: %{"context" => context}}, _), do: context
238 def make_context(_, _), do: Utils.generate_context_id()
239
240 def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
241
242 def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
243 text = add_attachments(text, attachments)
244 {text, mentions, tags}
245 end
246
247 def add_attachments(text, attachments) do
248 attachment_text = Enum.map(attachments, &build_attachment_link/1)
249 Enum.join([text | attachment_text], "<br>")
250 end
251
252 defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
253 name = attachment["name"] || URI.decode(Path.basename(href))
254 href = MediaProxy.url(href)
255 "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
256 end
257
258 defp build_attachment_link(_), do: ""
259
260 def format_input(text, format, options \\ [])
261
262 @doc """
263 Formatting text to plain text, BBCode, HTML, or Markdown
264 """
265 def format_input(text, "text/plain", options) do
266 text
267 |> Formatter.html_escape("text/plain")
268 |> Formatter.linkify(options)
269 |> (fn {text, mentions, tags} ->
270 {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
271 end).()
272 end
273
274 def format_input(text, "text/bbcode", options) do
275 text
276 |> String.replace(~r/\r/, "")
277 |> Formatter.html_escape("text/plain")
278 |> BBCode.to_html()
279 |> (fn {:ok, html} -> html end).()
280 |> Formatter.linkify(options)
281 end
282
283 def format_input(text, "text/html", options) do
284 text
285 |> Formatter.html_escape("text/html")
286 |> Formatter.linkify(options)
287 end
288
289 def format_input(text, "text/x.misskeymarkdown", options) do
290 text
291 |> Formatter.markdown_to_html()
292 |> MfmParser.Parser.parse()
293 |> MfmParser.Encoder.to_html()
294 |> Formatter.linkify(options)
295 |> Formatter.html_escape("text/html")
296 end
297
298 def format_input(text, "text/markdown", options) do
299 text
300 |> Formatter.mentions_escape(options)
301 |> Formatter.markdown_to_html()
302 |> Formatter.linkify(options)
303 |> Formatter.html_escape("text/html")
304 end
305
306 def format_naive_asctime(date) do
307 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
308 end
309
310 def format_asctime(date) do
311 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
312 end
313
314 def date_to_asctime(date) when is_binary(date) do
315 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
316 format_asctime(date)
317 else
318 _e ->
319 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
320 ""
321 end
322 end
323
324 def date_to_asctime(date) do
325 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
326 ""
327 end
328
329 def to_masto_date(%NaiveDateTime{} = date) do
330 date
331 |> NaiveDateTime.to_iso8601()
332 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
333 end
334
335 def to_masto_date(date) when is_binary(date) do
336 with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
337 to_masto_date(date)
338 else
339 _ -> ""
340 end
341 end
342
343 def to_masto_date(_), do: ""
344
345 defp shortname(name) do
346 with max_length when max_length > 0 <-
347 Config.get([Pleroma.Upload, :filename_display_max_length], 30),
348 true <- String.length(name) > max_length do
349 String.slice(name, 0..max_length) <> "…"
350 else
351 _ -> name
352 end
353 end
354
355 @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
356 def confirm_current_password(user, password) do
357 with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
358 true <- Pleroma.Password.checkpw(password, db_user.password_hash) do
359 {:ok, db_user}
360 else
361 _ -> {:error, dgettext("errors", "Invalid password.")}
362 end
363 end
364
365 def maybe_notify_to_recipients(
366 recipients,
367 %Activity{data: %{"to" => to, "type" => _type}} = _activity
368 ) do
369 recipients ++ to
370 end
371
372 def maybe_notify_to_recipients(recipients, _), do: recipients
373
374 def maybe_notify_mentioned_recipients(
375 recipients,
376 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
377 )
378 when type == "Create" do
379 object = Object.normalize(activity, fetch: false)
380
381 object_data =
382 cond do
383 not is_nil(object) ->
384 object.data
385
386 is_map(data["object"]) ->
387 data["object"]
388
389 true ->
390 %{}
391 end
392
393 tagged_mentions = maybe_extract_mentions(object_data)
394
395 recipients ++ tagged_mentions
396 end
397
398 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
399
400 def maybe_notify_subscribers(
401 recipients,
402 %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
403 ) do
404 # Do not notify subscribers if author is making a reply
405 with %Object{data: object} <- Object.normalize(activity, fetch: false),
406 nil <- object["inReplyTo"],
407 %User{} = user <- User.get_cached_by_ap_id(actor) do
408 subscriber_ids =
409 user
410 |> User.subscriber_users()
411 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
412 |> Enum.map(& &1.ap_id)
413
414 recipients ++ subscriber_ids
415 else
416 _e -> recipients
417 end
418 end
419
420 def maybe_notify_subscribers(recipients, _), do: recipients
421
422 def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
423 with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
424 user
425 |> User.get_followers()
426 |> Enum.map(& &1.ap_id)
427 |> Enum.concat(recipients)
428 else
429 _e -> recipients
430 end
431 end
432
433 def maybe_notify_followers(recipients, _), do: recipients
434
435 def maybe_extract_mentions(%{"tag" => tag}) do
436 tag
437 |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
438 |> Enum.map(fn x -> x["href"] end)
439 |> Enum.uniq()
440 end
441
442 def maybe_extract_mentions(_), do: []
443
444 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
445
446 def make_report_content_html(comment) do
447 max_size = Config.get([:instance, :max_report_comment_size], 1000)
448
449 if String.length(comment) <= max_size do
450 {:ok, format_input(comment, "text/plain")}
451 else
452 {:error,
453 dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
454 end
455 end
456
457 def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
458 when is_list(status_ids) do
459 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
460 end
461
462 def get_report_statuses(_, _), do: {:ok, nil}
463
464 def validate_character_limit("" = _full_payload, [] = _attachments) do
465 {:error, dgettext("errors", "Cannot post an empty status without attachments")}
466 end
467
468 def validate_character_limit(full_payload, _attachments) do
469 limit = Config.get([:instance, :limit])
470 length = String.length(full_payload)
471
472 if length <= limit do
473 :ok
474 else
475 {:error, dgettext("errors", "The status is over the character limit")}
476 end
477 end
478 end