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