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