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