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