Merge branch 'develop' into feature/database-compaction
[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 to html.
187 """
188 def format_input(text, "text/html", options) do
189 text
190 |> Formatter.html_escape("text/html")
191 |> Formatter.linkify(options)
192 end
193
194 @doc """
195 Formatting text to markdown.
196 """
197 def format_input(text, "text/markdown", options) do
198 text
199 |> Formatter.mentions_escape(options)
200 |> Earmark.as_html!()
201 |> Formatter.linkify(options)
202 |> Formatter.html_escape("text/html")
203 end
204
205 def make_note_data(
206 actor,
207 to,
208 context,
209 content_html,
210 attachments,
211 inReplyTo,
212 tags,
213 cw \\ nil,
214 cc \\ []
215 ) do
216 object = %{
217 "type" => "Note",
218 "to" => to,
219 "cc" => cc,
220 "content" => content_html,
221 "summary" => cw,
222 "context" => context,
223 "attachment" => attachments,
224 "actor" => actor,
225 "tag" => tags |> Enum.map(fn {_, tag} -> tag end) |> Enum.uniq()
226 }
227
228 if inReplyTo do
229 inReplyToObject = Object.normalize(inReplyTo.data["object"])
230
231 object
232 |> Map.put("inReplyTo", inReplyToObject.data["id"])
233 else
234 object
235 end
236 end
237
238 def format_naive_asctime(date) do
239 date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
240 end
241
242 def format_asctime(date) do
243 Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
244 end
245
246 def date_to_asctime(date) when is_binary(date) do
247 with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
248 format_asctime(date)
249 else
250 _e ->
251 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
252 ""
253 end
254 end
255
256 def date_to_asctime(date) do
257 Logger.warn("Date #{date} in wrong format, must be ISO 8601")
258 ""
259 end
260
261 def to_masto_date(%NaiveDateTime{} = date) do
262 date
263 |> NaiveDateTime.to_iso8601()
264 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
265 end
266
267 def to_masto_date(date) do
268 try do
269 date
270 |> NaiveDateTime.from_iso8601!()
271 |> NaiveDateTime.to_iso8601()
272 |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
273 rescue
274 _e -> ""
275 end
276 end
277
278 defp shortname(name) do
279 if String.length(name) < 30 do
280 name
281 else
282 String.slice(name, 0..30) <> "…"
283 end
284 end
285
286 def confirm_current_password(user, password) do
287 with %User{local: true} = db_user <- User.get_by_id(user.id),
288 true <- Pbkdf2.checkpw(password, db_user.password_hash) do
289 {:ok, db_user}
290 else
291 _ -> {:error, "Invalid password."}
292 end
293 end
294
295 def emoji_from_profile(%{info: _info} = user) do
296 (Formatter.get_emoji(user.bio) ++ Formatter.get_emoji(user.name))
297 |> Enum.map(fn {shortcode, url, _} ->
298 %{
299 "type" => "Emoji",
300 "icon" => %{"type" => "Image", "url" => "#{Endpoint.url()}#{url}"},
301 "name" => ":#{shortcode}:"
302 }
303 end)
304 end
305
306 def maybe_notify_to_recipients(
307 recipients,
308 %Activity{data: %{"to" => to, "type" => _type}} = _activity
309 ) do
310 recipients ++ to
311 end
312
313 def maybe_notify_mentioned_recipients(
314 recipients,
315 %Activity{data: %{"to" => _to, "type" => type} = data} = activity
316 )
317 when type == "Create" do
318 object = Object.normalize(activity)
319
320 object_data =
321 cond do
322 !is_nil(object) ->
323 object.data
324
325 is_map(data["object"]) ->
326 data["object"]
327
328 true ->
329 %{}
330 end
331
332 tagged_mentions = maybe_extract_mentions(object_data)
333
334 recipients ++ tagged_mentions
335 end
336
337 def maybe_notify_mentioned_recipients(recipients, _), do: recipients
338
339 def maybe_notify_subscribers(
340 recipients,
341 %Activity{data: %{"actor" => actor, "type" => type}} = activity
342 )
343 when type == "Create" do
344 with %User{} = user <- User.get_cached_by_ap_id(actor) do
345 subscriber_ids =
346 user
347 |> User.subscribers()
348 |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
349 |> Enum.map(& &1.ap_id)
350
351 recipients ++ subscriber_ids
352 end
353 end
354
355 def maybe_notify_subscribers(recipients, _), do: recipients
356
357 def maybe_extract_mentions(%{"tag" => tag}) do
358 tag
359 |> Enum.filter(fn x -> is_map(x) end)
360 |> Enum.filter(fn x -> x["type"] == "Mention" end)
361 |> Enum.map(fn x -> x["href"] end)
362 end
363
364 def maybe_extract_mentions(_), do: []
365
366 def make_report_content_html(nil), do: {:ok, {nil, [], []}}
367
368 def make_report_content_html(comment) do
369 max_size = Pleroma.Config.get([:instance, :max_report_comment_size], 1000)
370
371 if String.length(comment) <= max_size do
372 {:ok, format_input(comment, "text/plain")}
373 else
374 {:error, "Comment must be up to #{max_size} characters"}
375 end
376 end
377
378 def get_report_statuses(%User{ap_id: actor}, %{"status_ids" => status_ids}) do
379 {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
380 end
381
382 def get_report_statuses(_, _), do: {:ok, nil}
383
384 # DEPRECATED mostly, context objects are now created at insertion time.
385 def context_to_conversation_id(context) do
386 with %Object{id: id} <- Object.get_cached_by_ap_id(context) do
387 id
388 else
389 _e ->
390 changeset = Object.context_mapping(context)
391
392 case Repo.insert(changeset) do
393 {:ok, %{id: id}} ->
394 id
395
396 # This should be solved by an upsert, but it seems ecto
397 # has problems accessing the constraint inside the jsonb.
398 {:error, _} ->
399 Object.get_cached_by_ap_id(context).id
400 end
401 end
402 end
403
404 def conversation_id_to_context(id) do
405 with %Object{data: %{"id" => context}} <- Repo.get(Object, id) do
406 context
407 else
408 _e ->
409 {:error, "No such conversation"}
410 end
411 end
412 end