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