Fork of Pleroma with site-specific changes and feature branches https://git.pleroma.social/pleroma/pleroma
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

566 行
15KB

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