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