logo

pleroma

My custom branche(s) on git.pleroma.social/pleroma/pleroma git clone https://hacktivis.me/git/pleroma.git

utils.ex (14453B)


  1. # Pleroma: A lightweight social networking server
  2. # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
  3. # SPDX-License-Identifier: AGPL-3.0-only
  4. defmodule Pleroma.Web.CommonAPI.Utils do
  5. import Pleroma.Web.Gettext
  6. alias Calendar.Strftime
  7. alias Pleroma.Activity
  8. alias Pleroma.Config
  9. alias Pleroma.Conversation.Participation
  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.CommonAPI.ActivityDraft
  17. alias Pleroma.Web.MediaProxy
  18. alias Pleroma.Web.Plugs.AuthenticationPlug
  19. alias Pleroma.Web.Utils.Params
  20. require Logger
  21. require Pleroma.Constants
  22. def attachments_from_ids(%{media_ids: ids, descriptions: desc}, user) do
  23. attachments_from_ids_descs(ids, desc, user)
  24. end
  25. def attachments_from_ids(%{media_ids: ids}, user) do
  26. attachments_from_ids_no_descs(ids, user)
  27. end
  28. def attachments_from_ids(_, _), do: []
  29. def attachments_from_ids_no_descs([], _), do: []
  30. def attachments_from_ids_no_descs(ids, user) do
  31. Enum.map(ids, fn media_id ->
  32. case get_attachment(media_id, user) do
  33. %Object{data: data} -> data
  34. _ -> nil
  35. end
  36. end)
  37. |> Enum.reject(&is_nil/1)
  38. end
  39. def attachments_from_ids_descs([], _, _), do: []
  40. def attachments_from_ids_descs(ids, descs_str, user) do
  41. {_, descs} = Jason.decode(descs_str)
  42. Enum.map(ids, fn media_id ->
  43. with %Object{data: data} <- get_attachment(media_id, user) do
  44. Map.put(data, "name", descs[media_id])
  45. end
  46. end)
  47. |> Enum.reject(&is_nil/1)
  48. end
  49. defp get_attachment(media_id, user) do
  50. with %Object{data: data} = object <- Repo.get(Object, media_id),
  51. %{"type" => type} when type in Pleroma.Constants.upload_object_types() <- data,
  52. :ok <- Object.authorize_access(object, user) do
  53. object
  54. else
  55. _ -> nil
  56. end
  57. end
  58. @spec get_to_and_cc(ActivityDraft.t()) :: {list(String.t()), list(String.t())}
  59. def get_to_and_cc(%{in_reply_to_conversation: %Participation{} = participation}) do
  60. participation = Repo.preload(participation, :recipients)
  61. {Enum.map(participation.recipients, & &1.ap_id), []}
  62. end
  63. def get_to_and_cc(%{visibility: visibility} = draft) when visibility in ["public", "local"] do
  64. to =
  65. case visibility do
  66. "public" -> [Pleroma.Constants.as_public() | draft.mentions]
  67. "local" -> [Utils.as_local_public() | draft.mentions]
  68. end
  69. cc = [draft.user.follower_address]
  70. if draft.in_reply_to do
  71. {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
  72. else
  73. {to, cc}
  74. end
  75. end
  76. def get_to_and_cc(%{visibility: "unlisted"} = draft) do
  77. to = [draft.user.follower_address | draft.mentions]
  78. cc = [Pleroma.Constants.as_public()]
  79. if draft.in_reply_to do
  80. {Enum.uniq([draft.in_reply_to.data["actor"] | to]), cc}
  81. else
  82. {to, cc}
  83. end
  84. end
  85. def get_to_and_cc(%{visibility: "private"} = draft) do
  86. {to, cc} = get_to_and_cc(struct(draft, visibility: "direct"))
  87. {[draft.user.follower_address | to], cc}
  88. end
  89. def get_to_and_cc(%{visibility: "direct"} = draft) do
  90. # If the OP is a DM already, add the implicit actor.
  91. if draft.in_reply_to && Visibility.direct?(draft.in_reply_to) do
  92. {Enum.uniq([draft.in_reply_to.data["actor"] | draft.mentions]), []}
  93. else
  94. {draft.mentions, []}
  95. end
  96. end
  97. def get_to_and_cc(%{visibility: {:list, _}, mentions: mentions}), do: {mentions, []}
  98. def get_addressed_users(_, to) when is_list(to) do
  99. User.get_ap_ids_by_nicknames(to)
  100. end
  101. def get_addressed_users(mentioned_users, _), do: mentioned_users
  102. def maybe_add_list_data(activity_params, user, {:list, list_id}) do
  103. case Pleroma.List.get(list_id, user) do
  104. %Pleroma.List{} = list ->
  105. activity_params
  106. |> put_in([:additional, "bcc"], [list.ap_id])
  107. |> put_in([:additional, "listMessage"], list.ap_id)
  108. |> put_in([:object, "listMessage"], list.ap_id)
  109. _ ->
  110. activity_params
  111. end
  112. end
  113. def maybe_add_list_data(activity_params, _, _), do: activity_params
  114. def make_poll_data(%{"poll" => %{"expires_in" => expires_in}} = data)
  115. when is_binary(expires_in) do
  116. # In some cases mastofe sends out strings instead of integers
  117. data
  118. |> put_in(["poll", "expires_in"], String.to_integer(expires_in))
  119. |> make_poll_data()
  120. end
  121. def make_poll_data(%{poll: %{options: options, expires_in: expires_in}} = data)
  122. when is_list(options) do
  123. limits = Config.get([:instance, :poll_limits])
  124. options = options |> Enum.uniq()
  125. with :ok <- validate_poll_expiration(expires_in, limits),
  126. :ok <- validate_poll_options_amount(options, limits),
  127. :ok <- validate_poll_options_length(options, limits) do
  128. {option_notes, emoji} =
  129. Enum.map_reduce(options, %{}, fn option, emoji ->
  130. note = %{
  131. "name" => option,
  132. "type" => "Note",
  133. "replies" => %{"type" => "Collection", "totalItems" => 0}
  134. }
  135. {note, Map.merge(emoji, Pleroma.Emoji.Formatter.get_emoji_map(option))}
  136. end)
  137. end_time =
  138. DateTime.utc_now()
  139. |> DateTime.add(expires_in)
  140. |> DateTime.to_iso8601()
  141. key = if Params.truthy_param?(data.poll[:multiple]), do: "anyOf", else: "oneOf"
  142. poll = %{"type" => "Question", key => option_notes, "closed" => end_time}
  143. {:ok, {poll, emoji}}
  144. end
  145. end
  146. def make_poll_data(%{"poll" => poll}) when is_map(poll) do
  147. {:error, "Invalid poll"}
  148. end
  149. def make_poll_data(_data) do
  150. {:ok, {%{}, %{}}}
  151. end
  152. defp validate_poll_options_amount(options, %{max_options: max_options}) do
  153. cond do
  154. Enum.count(options) < 2 ->
  155. {:error, "Poll must contain at least 2 options"}
  156. Enum.count(options) > max_options ->
  157. {:error, "Poll can't contain more than #{max_options} options"}
  158. true ->
  159. :ok
  160. end
  161. end
  162. defp validate_poll_options_length(options, %{max_option_chars: max_option_chars}) do
  163. if Enum.any?(options, &(String.length(&1) > max_option_chars)) do
  164. {:error, "Poll options cannot be longer than #{max_option_chars} characters each"}
  165. else
  166. :ok
  167. end
  168. end
  169. defp validate_poll_expiration(expires_in, %{min_expiration: min, max_expiration: max}) do
  170. cond do
  171. expires_in > max -> {:error, "Expiration date is too far in the future"}
  172. expires_in < min -> {:error, "Expiration date is too soon"}
  173. true -> :ok
  174. end
  175. end
  176. def make_content_html(%ActivityDraft{} = draft) do
  177. attachment_links =
  178. draft.params
  179. |> Map.get("attachment_links", Config.get([:instance, :attachment_links]))
  180. |> Params.truthy_param?()
  181. content_type = get_content_type(draft.params[:content_type])
  182. options =
  183. if draft.visibility == "direct" && Config.get([:instance, :safe_dm_mentions]) do
  184. [safe_mention: true]
  185. else
  186. []
  187. end
  188. draft.status
  189. |> format_input(content_type, options)
  190. |> maybe_add_attachments(draft.attachments, attachment_links)
  191. end
  192. def get_content_type(content_type) do
  193. if Enum.member?(Config.get([:instance, :allowed_post_formats]), content_type) do
  194. content_type
  195. else
  196. "text/plain"
  197. end
  198. end
  199. def make_context(_, %Participation{} = participation) do
  200. Repo.preload(participation, :conversation).conversation.ap_id
  201. end
  202. def make_context(%Activity{data: %{"context" => context}}, _), do: context
  203. def make_context(_, _), do: Utils.generate_context_id()
  204. def maybe_add_attachments(parsed, _attachments, false = _no_links), do: parsed
  205. def maybe_add_attachments({text, mentions, tags}, attachments, _no_links) do
  206. text = add_attachments(text, attachments)
  207. {text, mentions, tags}
  208. end
  209. def add_attachments(text, attachments) do
  210. attachment_text = Enum.map(attachments, &build_attachment_link/1)
  211. Enum.join([text | attachment_text], "<br>")
  212. end
  213. defp build_attachment_link(%{"url" => [%{"href" => href} | _]} = attachment) do
  214. name = attachment["name"] || URI.decode(Path.basename(href))
  215. href = MediaProxy.url(href)
  216. "<a href=\"#{href}\" class='attachment'>#{shortname(name)}</a>"
  217. end
  218. defp build_attachment_link(_), do: ""
  219. def format_input(text, format, options \\ [])
  220. @doc """
  221. Formatting text to plain text, BBCode, HTML, or Markdown
  222. """
  223. def format_input(text, "text/plain", options) do
  224. text
  225. |> Formatter.html_escape("text/plain")
  226. |> Formatter.linkify(options)
  227. |> (fn {text, mentions, tags} ->
  228. {String.replace(text, ~r/\r?\n/, "<br>"), mentions, tags}
  229. end).()
  230. end
  231. def format_input(text, "text/bbcode", options) do
  232. text
  233. |> String.replace(~r/\r/, "")
  234. |> Formatter.html_escape("text/plain")
  235. |> BBCode.to_html()
  236. |> (fn {:ok, html} -> html end).()
  237. |> Formatter.linkify(options)
  238. end
  239. def format_input(text, "text/html", options) do
  240. text
  241. |> Formatter.html_escape("text/html")
  242. |> Formatter.linkify(options)
  243. end
  244. def format_input(text, "text/markdown", options) do
  245. text
  246. |> Formatter.mentions_escape(options)
  247. |> Formatter.markdown_to_html()
  248. |> Formatter.linkify(options)
  249. |> Formatter.html_escape("text/html")
  250. end
  251. def format_naive_asctime(date) do
  252. date |> DateTime.from_naive!("Etc/UTC") |> format_asctime
  253. end
  254. def format_asctime(date) do
  255. Strftime.strftime!(date, "%a %b %d %H:%M:%S %z %Y")
  256. end
  257. def date_to_asctime(date) when is_binary(date) do
  258. with {:ok, date, _offset} <- DateTime.from_iso8601(date) do
  259. format_asctime(date)
  260. else
  261. _e ->
  262. Logger.warning("Date #{date} in wrong format, must be ISO 8601")
  263. ""
  264. end
  265. end
  266. def date_to_asctime(date) do
  267. Logger.warning("Date #{date} in wrong format, must be ISO 8601")
  268. ""
  269. end
  270. def to_masto_date(%NaiveDateTime{} = date) do
  271. date
  272. |> NaiveDateTime.to_iso8601()
  273. |> String.replace(~r/(\.\d+)?$/, ".000Z", global: false)
  274. end
  275. def to_masto_date(date) when is_binary(date) do
  276. with {:ok, date} <- NaiveDateTime.from_iso8601(date) do
  277. to_masto_date(date)
  278. else
  279. _ -> ""
  280. end
  281. end
  282. def to_masto_date(_), do: ""
  283. defp shortname(name) do
  284. with max_length when max_length > 0 <-
  285. Config.get([Pleroma.Upload, :filename_display_max_length], 30),
  286. true <- String.length(name) > max_length do
  287. String.slice(name, 0..max_length) <> "…"
  288. else
  289. _ -> name
  290. end
  291. end
  292. @spec confirm_current_password(User.t(), String.t()) :: {:ok, User.t()} | {:error, String.t()}
  293. def confirm_current_password(user, password) do
  294. with %User{local: true} = db_user <- User.get_cached_by_id(user.id),
  295. true <- AuthenticationPlug.checkpw(password, db_user.password_hash) do
  296. {:ok, db_user}
  297. else
  298. _ -> {:error, dgettext("errors", "Invalid password.")}
  299. end
  300. end
  301. def maybe_notify_to_recipients(
  302. recipients,
  303. %Activity{data: %{"to" => to, "type" => _type}} = _activity
  304. ) do
  305. recipients ++ to
  306. end
  307. def maybe_notify_to_recipients(recipients, _), do: recipients
  308. def maybe_notify_mentioned_recipients(
  309. recipients,
  310. %Activity{data: %{"to" => _to, "type" => type} = data} = activity
  311. )
  312. when type == "Create" do
  313. object = Object.normalize(activity, fetch: false)
  314. object_data =
  315. cond do
  316. not is_nil(object) ->
  317. object.data
  318. is_map(data["object"]) ->
  319. data["object"]
  320. true ->
  321. %{}
  322. end
  323. tagged_mentions = maybe_extract_mentions(object_data)
  324. recipients ++ tagged_mentions
  325. end
  326. def maybe_notify_mentioned_recipients(recipients, _), do: recipients
  327. def maybe_notify_subscribers(
  328. recipients,
  329. %Activity{data: %{"actor" => actor, "type" => "Create"}} = activity
  330. ) do
  331. # Do not notify subscribers if author is making a reply
  332. with %Object{data: object} <- Object.normalize(activity, fetch: false),
  333. nil <- object["inReplyTo"],
  334. %User{} = user <- User.get_cached_by_ap_id(actor) do
  335. subscriber_ids =
  336. user
  337. |> User.subscriber_users()
  338. |> Enum.filter(&Visibility.visible_for_user?(activity, &1))
  339. |> Enum.map(& &1.ap_id)
  340. recipients ++ subscriber_ids
  341. else
  342. _e -> recipients
  343. end
  344. end
  345. def maybe_notify_subscribers(recipients, _), do: recipients
  346. def maybe_notify_followers(recipients, %Activity{data: %{"type" => "Move"}} = activity) do
  347. with %User{} = user <- User.get_cached_by_ap_id(activity.actor) do
  348. user
  349. |> User.get_followers()
  350. |> Enum.map(& &1.ap_id)
  351. |> Enum.concat(recipients)
  352. else
  353. _e -> recipients
  354. end
  355. end
  356. def maybe_notify_followers(recipients, _), do: recipients
  357. def maybe_extract_mentions(%{"tag" => tag}) do
  358. tag
  359. |> Enum.filter(fn x -> is_map(x) && x["type"] == "Mention" end)
  360. |> Enum.map(fn x -> x["href"] end)
  361. |> Enum.uniq()
  362. end
  363. def maybe_extract_mentions(_), do: []
  364. def make_report_content_html(nil), do: {:ok, {nil, [], []}}
  365. def make_report_content_html(comment) do
  366. max_size = Config.get([:instance, :max_report_comment_size], 1000)
  367. if String.length(comment) <= max_size do
  368. {:ok, format_input(comment, "text/plain")}
  369. else
  370. {:error,
  371. dgettext("errors", "Comment must be up to %{max_size} characters", max_size: max_size)}
  372. end
  373. end
  374. def get_report_statuses(%User{ap_id: actor}, %{status_ids: status_ids})
  375. when is_list(status_ids) do
  376. {:ok, Activity.all_by_actor_and_id(actor, status_ids)}
  377. end
  378. def get_report_statuses(_, _), do: {:ok, nil}
  379. def validate_character_limit("" = _full_payload, [] = _attachments) do
  380. {:error, dgettext("errors", "Cannot post an empty status without attachments")}
  381. end
  382. def validate_character_limit(full_payload, _attachments) do
  383. limit = Config.get([:instance, :limit])
  384. length = String.length(full_payload)
  385. if length <= limit do
  386. :ok
  387. else
  388. {:error, dgettext("errors", "The status is over the character limit")}
  389. end
  390. end
  391. def validate_attachments_count([] = _attachments) do
  392. :ok
  393. end
  394. def validate_attachments_count(attachments) do
  395. limit = Config.get([:instance, :max_media_attachments])
  396. count = length(attachments)
  397. if count <= limit do
  398. :ok
  399. else
  400. {:error, dgettext("errors", "Too many attachments")}
  401. end
  402. end
  403. end