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