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