aboutsummaryrefslogtreecommitdiff
path: root/lib/pleroma/web/activity_pub/mrf
diff options
context:
space:
mode:
Diffstat (limited to 'lib/pleroma/web/activity_pub/mrf')
-rw-r--r--lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex61
-rw-r--r--lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex85
-rw-r--r--lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex60
-rw-r--r--lib/pleroma/web/activity_pub/mrf/drop_policy.ex18
-rw-r--r--lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex47
-rw-r--r--lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex63
-rw-r--r--lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex56
-rw-r--r--lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex135
-rw-r--r--lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex143
-rw-r--r--lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex127
-rw-r--r--lib/pleroma/web/activity_pub/mrf/keyword_policy.ex204
-rw-r--r--lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex72
-rw-r--r--lib/pleroma/web/activity_pub/mrf/mention_policy.ex46
-rw-r--r--lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex70
-rw-r--r--lib/pleroma/web/activity_pub/mrf/no_op_policy.ex16
-rw-r--r--lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex28
-rw-r--r--lib/pleroma/web/activity_pub/mrf/normalize_markup.ex49
-rw-r--r--lib/pleroma/web/activity_pub/mrf/object_age_policy.ex141
-rw-r--r--lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex7
-rw-r--r--lib/pleroma/web/activity_pub/mrf/policy.ex17
-rw-r--r--lib/pleroma/web/activity_pub/mrf/reject_non_public.ex74
-rw-r--r--lib/pleroma/web/activity_pub/mrf/simple_policy.ex370
-rw-r--r--lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex154
-rw-r--r--lib/pleroma/web/activity_pub/mrf/subchain_policy.ex64
-rw-r--r--lib/pleroma/web/activity_pub/mrf/tag_policy.ex163
-rw-r--r--lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex65
-rw-r--r--lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex69
27 files changed, 2404 insertions, 0 deletions
diff --git a/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex
new file mode 100644
index 0000000..88f6ca0
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/activity_expiration_policy.ex
@@ -0,0 +1,61 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy do
+ @moduledoc "Adds expiration to all local Create activities"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(activity) do
+ activity =
+ if note?(activity) and local?(activity) do
+ maybe_add_expiration(activity)
+ else
+ activity
+ end
+
+ {:ok, activity}
+ end
+
+ @impl true
+ def describe, do: {:ok, %{}}
+
+ defp local?(%{"actor" => actor}) do
+ String.starts_with?(actor, Pleroma.Web.Endpoint.url())
+ end
+
+ defp note?(activity) do
+ match?(%{"type" => "Create", "object" => %{"type" => "Note"}}, activity)
+ end
+
+ defp maybe_add_expiration(activity) do
+ days = Pleroma.Config.get([:mrf_activity_expiration, :days], 365)
+ expires_at = DateTime.utc_now() |> Timex.shift(days: days)
+
+ with %{"expires_at" => existing_expires_at} <- activity,
+ :lt <- DateTime.compare(existing_expires_at, expires_at) do
+ activity
+ else
+ _ -> Map.put(activity, "expires_at", expires_at)
+ end
+ end
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_activity_expiration,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.ActivityExpirationPolicy",
+ label: "MRF Activity Expiration Policy",
+ description: "Adds automatic expiration to all local activities",
+ children: [
+ %{
+ key: :days,
+ type: :integer,
+ description: "Default global expiration time for all local activities (in days)",
+ suggestions: [90, 365]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex
new file mode 100644
index 0000000..97d75ec
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/anti_followbot_policy.ex
@@ -0,0 +1,85 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.AntiFollowbotPolicy do
+ alias Pleroma.User
+
+ @moduledoc "Prevent followbots from following with a bit of heuristic"
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ # XXX: this should become User.normalize_by_ap_id() or similar, really.
+ defp normalize_by_ap_id(%{"id" => id}), do: User.get_cached_by_ap_id(id)
+ defp normalize_by_ap_id(uri) when is_binary(uri), do: User.get_cached_by_ap_id(uri)
+ defp normalize_by_ap_id(_), do: nil
+
+ defp score_nickname("followbot@" <> _), do: 1.0
+ defp score_nickname("federationbot@" <> _), do: 1.0
+ defp score_nickname("federation_bot@" <> _), do: 1.0
+ defp score_nickname(_), do: 0.0
+
+ defp score_displayname("federation bot"), do: 1.0
+ defp score_displayname("federationbot"), do: 1.0
+ defp score_displayname("fedibot"), do: 1.0
+ defp score_displayname(_), do: 0.0
+
+ defp determine_if_followbot(%User{nickname: nickname, name: displayname, actor_type: actor_type}) do
+ # nickname will be a binary string except when following a relay
+ nick_score =
+ if is_binary(nickname) do
+ nickname
+ |> String.downcase()
+ |> score_nickname()
+ else
+ 0.0
+ end
+
+ # displayname will either be a binary string or nil, if a displayname isn't set.
+ name_score =
+ if is_binary(displayname) do
+ displayname
+ |> String.downcase()
+ |> score_displayname()
+ else
+ 0.0
+ end
+
+ # actor_type "Service" is a Bot account
+ actor_type_score =
+ if actor_type == "Service" do
+ 1.0
+ else
+ 0.0
+ end
+
+ nick_score + name_score + actor_type_score
+ end
+
+ defp determine_if_followbot(_), do: 0.0
+
+ defp bot_allowed?(%{"object" => target}, bot_actor) do
+ %User{} = user = normalize_by_ap_id(target)
+
+ User.following?(user, bot_actor)
+ end
+
+ @impl true
+ def filter(%{"type" => "Follow", "actor" => actor_id} = message) do
+ %User{} = actor = normalize_by_ap_id(actor_id)
+
+ score = determine_if_followbot(actor)
+
+ if score < 0.8 || bot_allowed?(message, actor) do
+ {:ok, message}
+ else
+ {:reject, "[AntiFollowbotPolicy] Scored #{actor_id} as #{score}"}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
new file mode 100644
index 0000000..3ec9c52
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/anti_link_spam_policy.ex
@@ -0,0 +1,60 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.AntiLinkSpamPolicy do
+ alias Pleroma.User
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ require Logger
+
+ @impl true
+ def history_awareness, do: :auto
+
+ # has the user successfully posted before?
+ defp old_user?(%User{} = u) do
+ u.note_count > 0 || u.follower_count > 0
+ end
+
+ # does the post contain links?
+ defp contains_links?(%{"content" => content} = _object) do
+ content
+ |> Floki.parse_fragment!()
+ |> Floki.filter_out("a.mention,a.hashtag,a[rel~=\"tag\"],a.zrl")
+ |> Floki.attribute("a", "href")
+ |> length() > 0
+ end
+
+ defp contains_links?(_), do: false
+
+ @impl true
+ def filter(%{"type" => "Create", "actor" => actor, "object" => object} = message) do
+ with {:ok, %User{local: false} = u} <- User.get_or_fetch_by_ap_id(actor),
+ {:contains_links, true} <- {:contains_links, contains_links?(object)},
+ {:old_user, true} <- {:old_user, old_user?(u)} do
+ {:ok, message}
+ else
+ {:ok, %User{local: true}} ->
+ {:ok, message}
+
+ {:contains_links, false} ->
+ {:ok, message}
+
+ {:old_user, false} ->
+ {:reject, "[AntiLinkSpamPolicy] User has no posts nor followers"}
+
+ {:error, _} ->
+ {:reject, "[AntiLinkSpamPolicy] Failed to get or fetch user by ap_id"}
+
+ e ->
+ {:reject, "[AntiLinkSpamPolicy] Unhandled error #{inspect(e)}"}
+ end
+ end
+
+ # in all other cases, pass through
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/drop_policy.ex b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex
new file mode 100644
index 0000000..ad09368
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/drop_policy.ex
@@ -0,0 +1,18 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.DropPolicy do
+ require Logger
+ @moduledoc "Drop and log everything received"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(object) do
+ Logger.debug("REJECTING #{inspect(object)}")
+ {:reject, object}
+ end
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
new file mode 100644
index 0000000..a148cc1
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/ensure_re_prepended.ex
@@ -0,0 +1,47 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.EnsureRePrepended do
+ alias Pleroma.Object
+
+ @moduledoc "Ensure a re: is prepended on replies to a post with a Subject"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @reply_prefix Regex.compile!("^re:[[:space:]]*", [:caseless])
+
+ def history_awareness, do: :auto
+
+ def filter_by_summary(
+ %{data: %{"summary" => parent_summary}} = _in_reply_to,
+ %{"summary" => child_summary} = child
+ )
+ when not is_nil(child_summary) and byte_size(child_summary) > 0 and
+ not is_nil(parent_summary) and byte_size(parent_summary) > 0 do
+ if (child_summary == parent_summary and not Regex.match?(@reply_prefix, child_summary)) or
+ (Regex.match?(@reply_prefix, parent_summary) &&
+ Regex.replace(@reply_prefix, parent_summary, "") == child_summary) do
+ Map.put(child, "summary", "re: " <> child_summary)
+ else
+ child
+ end
+ end
+
+ def filter_by_summary(_in_reply_to, child), do: child
+
+ def filter(%{"type" => type, "object" => child_object} = object)
+ when type in ["Create", "Update"] and is_map(child_object) do
+ child =
+ child_object["inReplyTo"]
+ |> Object.normalize(fetch: false)
+ |> filter_by_summary(child_object)
+
+ object = Map.put(object, "object", child)
+
+ {:ok, object}
+ end
+
+ def filter(object), do: {:ok, object}
+
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex
new file mode 100644
index 0000000..5b6adbb
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/follow_bot_policy.ex
@@ -0,0 +1,63 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.FollowBotPolicy do
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ alias Pleroma.Config
+ alias Pleroma.User
+ alias Pleroma.Web.CommonAPI
+
+ require Logger
+
+ @impl true
+ def filter(message) do
+ with follower_nickname <- Config.get([:mrf_follow_bot, :follower_nickname]),
+ %User{actor_type: "Service"} = follower <-
+ User.get_cached_by_nickname(follower_nickname),
+ %{"type" => "Create", "object" => %{"type" => "Note"}} <- message do
+ try_follow(follower, message)
+ else
+ nil ->
+ Logger.warn(
+ "#{__MODULE__} skipped because of missing `:mrf_follow_bot, :follower_nickname` configuration, the :follower_nickname
+ account does not exist, or the account is not correctly configured as a bot."
+ )
+
+ {:ok, message}
+
+ _ ->
+ {:ok, message}
+ end
+ end
+
+ defp try_follow(follower, message) do
+ to = Map.get(message, "to", [])
+ cc = Map.get(message, "cc", [])
+ actor = [message["actor"]]
+
+ Enum.concat([to, cc, actor])
+ |> List.flatten()
+ |> Enum.uniq()
+ |> User.get_all_by_ap_id()
+ |> Enum.each(fn user ->
+ with false <- user.local,
+ false <- User.following?(follower, user),
+ false <- User.locked?(user),
+ false <- (user.bio || "") |> String.downcase() |> String.contains?("nobot") do
+ Logger.debug(
+ "#{__MODULE__}: Follow request from #{follower.nickname} to #{user.nickname}"
+ )
+
+ CommonAPI.follow(follower, user)
+ end
+ end)
+
+ {:ok, message}
+ end
+
+ @impl true
+ def describe do
+ {:ok, %{}}
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex
new file mode 100644
index 0000000..8cec8ea
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/force_bot_unlisted_policy.ex
@@ -0,0 +1,56 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceBotUnlistedPolicy do
+ alias Pleroma.User
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ @moduledoc "Remove bot posts from federated timeline"
+
+ require Pleroma.Constants
+
+ defp check_by_actor_type(user), do: user.actor_type in ["Application", "Service"]
+ defp check_by_nickname(user), do: Regex.match?(~r/.bot@|ebooks@/i, user.nickname)
+
+ defp check_if_bot(user), do: check_by_actor_type(user) or check_by_nickname(user)
+
+ @impl true
+ def filter(
+ %{
+ "type" => "Create",
+ "to" => to,
+ "cc" => cc,
+ "actor" => actor,
+ "object" => object
+ } = message
+ ) do
+ user = User.get_cached_by_ap_id(actor)
+ isbot = check_if_bot(user)
+
+ if isbot and Enum.member?(to, Pleroma.Constants.as_public()) do
+ to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
+ cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()]
+
+ object =
+ object
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Map.put("object", object)
+
+ {:ok, message}
+ else
+ {:ok, message}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex
new file mode 100644
index 0000000..7022456
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/force_mentions_in_content.ex
@@ -0,0 +1,135 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ForceMentionsInContent do
+ require Pleroma.Constants
+
+ alias Pleroma.Formatter
+ alias Pleroma.Object
+ alias Pleroma.User
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def history_awareness, do: :auto
+
+ defp do_extract({:a, attrs, _}, acc) do
+ if Enum.find(attrs, fn {name, value} ->
+ name == "class" && value in ["mention", "u-url mention", "mention u-url"]
+ end) do
+ href = Enum.find(attrs, fn {name, _} -> name == "href" end) |> elem(1)
+ acc ++ [href]
+ else
+ acc
+ end
+ end
+
+ defp do_extract({_, _, children}, acc) do
+ do_extract(children, acc)
+ end
+
+ defp do_extract(nodes, acc) when is_list(nodes) do
+ Enum.reduce(nodes, acc, fn node, acc -> do_extract(node, acc) end)
+ end
+
+ defp do_extract(_, acc), do: acc
+
+ defp extract_mention_uris_from_content(content) do
+ {:ok, tree} = :fast_html.decode(content, format: [:html_atoms])
+ do_extract(tree, [])
+ end
+
+ defp get_replied_to_user(%{"inReplyTo" => in_reply_to}) do
+ case Object.normalize(in_reply_to, fetch: false) do
+ %Object{data: %{"actor" => actor}} -> User.get_cached_by_ap_id(actor)
+ _ -> nil
+ end
+ end
+
+ defp get_replied_to_user(_object), do: nil
+
+ # Ensure the replied-to user is sorted to the left
+ defp sort_replied_user([%User{id: user_id} | _] = users, %User{id: user_id}), do: users
+
+ defp sort_replied_user(users, %User{id: user_id} = user) do
+ if Enum.find(users, fn u -> u.id == user_id end) do
+ users = Enum.reject(users, fn u -> u.id == user_id end)
+ [user | users]
+ else
+ users
+ end
+ end
+
+ defp sort_replied_user(users, _), do: users
+
+ # Drop constants and the actor's own AP ID
+ defp clean_recipients(recipients, object) do
+ Enum.reject(recipients, fn ap_id ->
+ ap_id in [
+ object["object"]["actor"],
+ Pleroma.Constants.as_public(),
+ Pleroma.Web.ActivityPub.Utils.as_local_public()
+ ]
+ end)
+ end
+
+ @impl true
+ def filter(
+ %{
+ "type" => type,
+ "object" => %{"type" => "Note", "to" => to, "inReplyTo" => in_reply_to}
+ } = object
+ )
+ when type in ["Create", "Update"] and is_list(to) and is_binary(in_reply_to) do
+ # image-only posts from pleroma apparently reach this MRF without the content field
+ content = object["object"]["content"] || ""
+
+ # Get the replied-to user for sorting
+ replied_to_user = get_replied_to_user(object["object"])
+
+ mention_users =
+ to
+ |> clean_recipients(object)
+ |> Enum.map(&User.get_cached_by_ap_id/1)
+ |> Enum.reject(&is_nil/1)
+ |> sort_replied_user(replied_to_user)
+
+ explicitly_mentioned_uris = extract_mention_uris_from_content(content)
+
+ added_mentions =
+ Enum.reduce(mention_users, "", fn %User{ap_id: uri} = user, acc ->
+ unless uri in explicitly_mentioned_uris do
+ acc <> Formatter.mention_from_user(user, %{mentions_format: :compact}) <> " "
+ else
+ acc
+ end
+ end)
+
+ recipients_inline =
+ if added_mentions != "",
+ do: "<span class=\"recipients-inline\">#{added_mentions}</span>",
+ else: ""
+
+ content =
+ cond do
+ # For Markdown posts, insert the mentions inside the first <p> tag
+ recipients_inline != "" && String.starts_with?(content, "<p>") ->
+ "<p>" <> recipients_inline <> String.trim_leading(content, "<p>")
+
+ recipients_inline != "" ->
+ recipients_inline <> content
+
+ true ->
+ content
+ end
+
+ {:ok, put_in(object["object"]["content"], content)}
+ end
+
+ @impl true
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
new file mode 100644
index 0000000..b73fd97
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/hashtag_policy.ex
@@ -0,0 +1,143 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.HashtagPolicy do
+ require Pleroma.Constants
+
+ alias Pleroma.Config
+ alias Pleroma.Object
+
+ @moduledoc """
+ Reject, TWKN-remove or Set-Sensitive messsages with specific hashtags (without the leading #)
+
+ Note: This MRF Policy is always enabled, if you want to disable it you have to set empty lists.
+ """
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def history_awareness, do: :manual
+
+ defp check_reject(message, hashtags) do
+ if Enum.any?(Config.get([:mrf_hashtag, :reject]), fn match -> match in hashtags end) do
+ {:reject, "[HashtagPolicy] Matches with rejected keyword"}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp check_ftl_removal(%{"to" => to} = message, hashtags) do
+ if Pleroma.Constants.as_public() in to and
+ Enum.any?(Config.get([:mrf_hashtag, :federated_timeline_removal]), fn match ->
+ match in hashtags
+ end) do
+ to = List.delete(to, Pleroma.Constants.as_public())
+ cc = [Pleroma.Constants.as_public() | message["cc"] || []]
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Kernel.put_in(["object", "to"], to)
+ |> Kernel.put_in(["object", "cc"], cc)
+
+ {:ok, message}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp check_ftl_removal(message, _hashtags), do: {:ok, message}
+
+ defp check_sensitive(message) do
+ {:ok, new_object} =
+ Object.Updater.do_with_history(message["object"], fn object ->
+ hashtags = Object.hashtags(%Object{data: object})
+
+ if Enum.any?(Config.get([:mrf_hashtag, :sensitive]), fn match -> match in hashtags end) do
+ {:ok, Map.put(object, "sensitive", true)}
+ else
+ {:ok, object}
+ end
+ end)
+
+ {:ok, Map.put(message, "object", new_object)}
+ end
+
+ @impl true
+ def filter(%{"type" => type, "object" => object} = message) when type in ["Create", "Update"] do
+ history_items =
+ with %{"formerRepresentations" => %{"orderedItems" => items}} <- object do
+ items
+ else
+ _ -> []
+ end
+
+ historical_hashtags =
+ Enum.reduce(history_items, [], fn item, acc ->
+ acc ++ Object.hashtags(%Object{data: item})
+ end)
+
+ hashtags = Object.hashtags(%Object{data: object}) ++ historical_hashtags
+
+ if hashtags != [] do
+ with {:ok, message} <- check_reject(message, hashtags),
+ {:ok, message} <-
+ (if "type" == "Create" do
+ check_ftl_removal(message, hashtags)
+ else
+ {:ok, message}
+ end),
+ {:ok, message} <- check_sensitive(message) do
+ {:ok, message}
+ end
+ else
+ {:ok, message}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe do
+ mrf_hashtag =
+ Config.get(:mrf_hashtag)
+ |> Enum.into(%{})
+
+ {:ok, %{mrf_hashtag: mrf_hashtag}}
+ end
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_hashtag,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.HashtagPolicy",
+ label: "MRF Hashtag",
+ description: @moduledoc,
+ children: [
+ %{
+ key: :reject,
+ type: {:list, :string},
+ description: "A list of hashtags which result in message being rejected.",
+ suggestions: ["foo"]
+ },
+ %{
+ key: :federated_timeline_removal,
+ type: {:list, :string},
+ description:
+ "A list of hashtags which result in message being removed from federated timelines (a.k.a unlisted).",
+ suggestions: ["foo"]
+ },
+ %{
+ key: :sensitive,
+ type: {:list, :string},
+ description:
+ "A list of hashtags which result in message being set as sensitive (a.k.a NSFW/R-18)",
+ suggestions: ["nsfw", "r18"]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex
new file mode 100644
index 0000000..80e235d
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/hellthread_policy.ex
@@ -0,0 +1,127 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.HellthreadPolicy do
+ alias Pleroma.User
+
+ require Pleroma.Constants
+
+ @moduledoc "Block messages with too much mentions (configurable)"
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp delist_message(message, threshold) when threshold > 0 do
+ follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address
+ to = message["to"] || []
+ cc = message["cc"] || []
+
+ follower_collection? = Enum.member?(to ++ cc, follower_collection)
+
+ message =
+ case get_recipient_count(message) do
+ {:public, recipients}
+ when follower_collection? and recipients > threshold ->
+ message
+ |> Map.put("to", [follower_collection])
+ |> Map.put("cc", [Pleroma.Constants.as_public()])
+
+ {:public, recipients} when recipients > threshold ->
+ message
+ |> Map.put("to", [])
+ |> Map.put("cc", [Pleroma.Constants.as_public()])
+
+ _ ->
+ message
+ end
+
+ {:ok, message}
+ end
+
+ defp delist_message(message, _threshold), do: {:ok, message}
+
+ defp reject_message(message, threshold) when threshold > 0 do
+ with {_, recipients} <- get_recipient_count(message) do
+ if recipients > threshold do
+ {:reject, "[HellthreadPolicy] #{recipients} recipients is over the limit of #{threshold}"}
+ else
+ {:ok, message}
+ end
+ end
+ end
+
+ defp reject_message(message, _threshold), do: {:ok, message}
+
+ defp get_recipient_count(message) do
+ recipients = (message["to"] || []) ++ (message["cc"] || [])
+ follower_collection = User.get_cached_by_ap_id(message["actor"]).follower_address
+
+ if Enum.member?(recipients, Pleroma.Constants.as_public()) do
+ recipients =
+ recipients
+ |> List.delete(Pleroma.Constants.as_public())
+ |> List.delete(follower_collection)
+
+ {:public, length(recipients)}
+ else
+ recipients =
+ recipients
+ |> List.delete(follower_collection)
+
+ {:not_public, length(recipients)}
+ end
+ end
+
+ @impl true
+ def filter(%{"type" => "Create", "object" => %{"type" => object_type}} = message)
+ when object_type in ~w{Note Article} do
+ reject_threshold =
+ Pleroma.Config.get(
+ [:mrf_hellthread, :reject_threshold],
+ Pleroma.Config.get([:mrf_hellthread, :threshold])
+ )
+
+ delist_threshold = Pleroma.Config.get([:mrf_hellthread, :delist_threshold])
+
+ with {:ok, message} <- reject_message(message, reject_threshold),
+ {:ok, message} <- delist_message(message, delist_threshold) do
+ {:ok, message}
+ else
+ e -> e
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe,
+ do: {:ok, %{mrf_hellthread: Pleroma.Config.get(:mrf_hellthread) |> Enum.into(%{})}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_hellthread,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.HellthreadPolicy",
+ label: "MRF Hellthread",
+ description: "Block messages with excessive user mentions",
+ children: [
+ %{
+ key: :delist_threshold,
+ type: :integer,
+ description:
+ "Number of mentioned users after which the message gets removed from timelines and" <>
+ "disables notifications. Set to 0 to disable.",
+ suggestions: [10]
+ },
+ %{
+ key: :reject_threshold,
+ type: :integer,
+ description:
+ "Number of mentioned users after which the messaged gets rejected. Set to 0 to disable.",
+ suggestions: [20]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
new file mode 100644
index 0000000..687ec6c
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/keyword_policy.ex
@@ -0,0 +1,204 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.KeywordPolicy do
+ require Pleroma.Constants
+
+ @moduledoc "Reject or Word-Replace messages with a keyword or regex"
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ defp string_matches?(string, _) when not is_binary(string) do
+ false
+ end
+
+ defp string_matches?(string, pattern) when is_binary(pattern) do
+ String.contains?(string, pattern)
+ end
+
+ defp string_matches?(string, pattern) do
+ String.match?(string, pattern)
+ end
+
+ defp object_payload(%{} = object) do
+ [object["content"], object["summary"], object["name"]]
+ |> Enum.filter(& &1)
+ |> Enum.join("\n")
+ end
+
+ defp check_reject(%{"object" => %{} = object} = message) do
+ with {:ok, _new_object} <-
+ Pleroma.Object.Updater.do_with_history(object, fn object ->
+ payload = object_payload(object)
+
+ if Enum.any?(Pleroma.Config.get([:mrf_keyword, :reject]), fn pattern ->
+ string_matches?(payload, pattern)
+ end) do
+ {:reject, "[KeywordPolicy] Matches with rejected keyword"}
+ else
+ {:ok, message}
+ end
+ end) do
+ {:ok, message}
+ else
+ e -> e
+ end
+ end
+
+ defp check_ftl_removal(%{"type" => "Create", "to" => to, "object" => %{} = object} = message) do
+ check_keyword = fn object ->
+ payload = object_payload(object)
+
+ if Enum.any?(Pleroma.Config.get([:mrf_keyword, :federated_timeline_removal]), fn pattern ->
+ string_matches?(payload, pattern)
+ end) do
+ {:should_delist, nil}
+ else
+ {:ok, %{}}
+ end
+ end
+
+ should_delist? = fn object ->
+ with {:ok, _} <- Pleroma.Object.Updater.do_with_history(object, check_keyword) do
+ false
+ else
+ _ -> true
+ end
+ end
+
+ if Pleroma.Constants.as_public() in to and should_delist?.(object) do
+ to = List.delete(to, Pleroma.Constants.as_public())
+ cc = [Pleroma.Constants.as_public() | message["cc"] || []]
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+
+ {:ok, message}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp check_ftl_removal(message) do
+ {:ok, message}
+ end
+
+ defp check_replace(%{"object" => %{} = object} = message) do
+ replace_kw = fn object ->
+ ["content", "name", "summary"]
+ |> Enum.filter(fn field -> Map.has_key?(object, field) && object[field] end)
+ |> Enum.reduce(object, fn field, object ->
+ data =
+ Enum.reduce(
+ Pleroma.Config.get([:mrf_keyword, :replace]),
+ object[field],
+ fn {pat, repl}, acc -> String.replace(acc, pat, repl) end
+ )
+
+ Map.put(object, field, data)
+ end)
+ |> (fn object -> {:ok, object} end).()
+ end
+
+ {:ok, object} = Pleroma.Object.Updater.do_with_history(object, replace_kw)
+
+ message = Map.put(message, "object", object)
+
+ {:ok, message}
+ end
+
+ @impl true
+ def filter(%{"type" => type, "object" => %{"content" => _content}} = message)
+ when type in ["Create", "Update"] do
+ with {:ok, message} <- check_reject(message),
+ {:ok, message} <- check_ftl_removal(message),
+ {:ok, message} <- check_replace(message) do
+ {:ok, message}
+ else
+ {:reject, nil} -> {:reject, "[KeywordPolicy] "}
+ {:reject, _} = e -> e
+ _e -> {:reject, "[KeywordPolicy] "}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe do
+ # This horror is needed to convert regex sigils to strings
+ mrf_keyword =
+ Pleroma.Config.get(:mrf_keyword, [])
+ |> Enum.map(fn {key, value} ->
+ {key,
+ Enum.map(value, fn
+ {pattern, replacement} ->
+ %{
+ "pattern" =>
+ if not is_binary(pattern) do
+ inspect(pattern)
+ else
+ pattern
+ end,
+ "replacement" => replacement
+ }
+
+ pattern ->
+ if not is_binary(pattern) do
+ inspect(pattern)
+ else
+ pattern
+ end
+ end)}
+ end)
+ |> Enum.into(%{})
+
+ {:ok, %{mrf_keyword: mrf_keyword}}
+ end
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_keyword,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.KeywordPolicy",
+ label: "MRF Keyword",
+ description:
+ "Reject or Word-Replace messages matching a keyword or [Regex](https://hexdocs.pm/elixir/Regex.html).",
+ children: [
+ %{
+ key: :reject,
+ type: {:list, :string},
+ description: """
+ A list of patterns which result in message being rejected.
+
+ Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
+ """,
+ suggestions: ["foo", ~r/foo/iu]
+ },
+ %{
+ key: :federated_timeline_removal,
+ type: {:list, :string},
+ description: """
+ A list of patterns which result in message being removed from federated timelines (a.k.a unlisted).
+
+ Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
+ """,
+ suggestions: ["foo", ~r/foo/iu]
+ },
+ %{
+ key: :replace,
+ type: {:list, :tuple},
+ key_placeholder: "instance",
+ value_placeholder: "reason",
+ description: """
+ **Pattern**: a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
+
+ **Replacement**: a string. Leaving the field empty is permitted.
+ """
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
new file mode 100644
index 0000000..c95d35b
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/media_proxy_warming_policy.ex
@@ -0,0 +1,72 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.MediaProxyWarmingPolicy do
+ @moduledoc "Preloads any attachments in the MediaProxy cache by prefetching them"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ alias Pleroma.HTTP
+ alias Pleroma.Web.MediaProxy
+
+ require Logger
+
+ @adapter_options [
+ pool: :media,
+ recv_timeout: 10_000
+ ]
+
+ @impl true
+ def history_awareness, do: :auto
+
+ defp prefetch(url) do
+ # Fetching only proxiable resources
+ if MediaProxy.enabled?() and MediaProxy.url_proxiable?(url) do
+ # If preview proxy is enabled, it'll also hit media proxy (so we're caching both requests)
+ prefetch_url = MediaProxy.preview_url(url)
+
+ Logger.debug("Prefetching #{inspect(url)} as #{inspect(prefetch_url)}")
+
+ if Pleroma.Config.get(:env) == :test do
+ fetch(prefetch_url)
+ else
+ ConcurrentLimiter.limit(__MODULE__, fn ->
+ Task.start(fn -> fetch(prefetch_url) end)
+ end)
+ end
+ end
+ end
+
+ defp fetch(url), do: HTTP.get(url, [], @adapter_options)
+
+ defp preload(%{"object" => %{"attachment" => attachments}} = _message) do
+ Enum.each(attachments, fn
+ %{"url" => url} when is_list(url) ->
+ url
+ |> Enum.each(fn
+ %{"href" => href} ->
+ prefetch(href)
+
+ x ->
+ Logger.debug("Unhandled attachment URL object #{inspect(x)}")
+ end)
+
+ x ->
+ Logger.debug("Unhandled attachment #{inspect(x)}")
+ end)
+ end
+
+ @impl true
+ def filter(%{"type" => type, "object" => %{"attachment" => attachments} = _object} = message)
+ when type in ["Create", "Update"] and is_list(attachments) and length(attachments) > 0 do
+ preload(message)
+
+ {:ok, message}
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/mention_policy.ex b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex
new file mode 100644
index 0000000..8aa4f34
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/mention_policy.ex
@@ -0,0 +1,46 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.MentionPolicy do
+ @moduledoc "Block messages which mention a user"
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(%{"type" => "Create"} = message) do
+ reject_actors = Pleroma.Config.get([:mrf_mention, :actors], [])
+ recipients = (message["to"] || []) ++ (message["cc"] || [])
+
+ if rejected_mention =
+ Enum.find(recipients, fn recipient -> Enum.member?(reject_actors, recipient) end) do
+ {:reject, "[MentionPolicy] Rejected for mention of #{rejected_mention}"}
+ else
+ {:ok, message}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_mention,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.MentionPolicy",
+ label: "MRF Mention",
+ description: "Block messages which mention a specific user",
+ children: [
+ %{
+ key: :actors,
+ type: {:list, :string},
+ description: "A list of actors for which any post mentioning them will be dropped",
+ suggestions: ["actor1", "actor2"]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
new file mode 100644
index 0000000..855cda3
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/no_empty_policy.ex
@@ -0,0 +1,70 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.NoEmptyPolicy do
+ @moduledoc "Filter local activities which have no content"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ alias Pleroma.Web.Endpoint
+
+ @impl true
+ def filter(%{"actor" => actor} = object) do
+ with true <- is_local?(actor),
+ true <- is_eligible_type?(object),
+ true <- is_note?(object),
+ false <- has_attachment?(object),
+ true <- only_mentions?(object) do
+ {:reject, "[NoEmptyPolicy]"}
+ else
+ _ ->
+ {:ok, object}
+ end
+ end
+
+ def filter(object), do: {:ok, object}
+
+ defp is_local?(actor) do
+ if actor |> String.starts_with?("#{Endpoint.url()}") do
+ true
+ else
+ false
+ end
+ end
+
+ defp has_attachment?(%{
+ "object" => %{"type" => "Note", "attachment" => attachments}
+ })
+ when length(attachments) > 0,
+ do: true
+
+ defp has_attachment?(_), do: false
+
+ defp only_mentions?(%{"object" => %{"type" => "Note", "source" => source}}) do
+ source =
+ case source do
+ %{"content" => text} -> text
+ _ -> source
+ end
+
+ non_mentions =
+ source |> String.split() |> Enum.filter(&(not String.starts_with?(&1, "@"))) |> length
+
+ if non_mentions > 0 do
+ false
+ else
+ true
+ end
+ end
+
+ defp only_mentions?(_), do: false
+
+ defp is_note?(%{"object" => %{"type" => "Note"}}), do: true
+ defp is_note?(_), do: false
+
+ defp is_eligible_type?(%{"type" => type}) when type in ["Create", "Update"], do: true
+ defp is_eligible_type?(_), do: false
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex
new file mode 100644
index 0000000..8840c4f
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/no_op_policy.ex
@@ -0,0 +1,16 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.NoOpPolicy do
+ @moduledoc "Does nothing (lets the messages go through unmodified)"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(object) do
+ {:ok, object}
+ end
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
new file mode 100644
index 0000000..f81e9e5
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/no_placeholder_text_policy.ex
@@ -0,0 +1,28 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.NoPlaceholderTextPolicy do
+ @moduledoc "Ensure no content placeholder is present (such as the dot from mastodon)"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def history_awareness, do: :auto
+
+ @impl true
+ def filter(
+ %{
+ "type" => type,
+ "object" => %{"content" => content, "attachment" => _} = _child_object
+ } = object
+ )
+ when type in ["Create", "Update"] and content in [".", "<p>.</p>"] do
+ {:ok, put_in(object, ["object", "content"], "")}
+ end
+
+ @impl true
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
new file mode 100644
index 0000000..2dfc9a9
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/normalize_markup.ex
@@ -0,0 +1,49 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.NormalizeMarkup do
+ @moduledoc "Scrub configured hypertext markup"
+ alias Pleroma.HTML
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def history_awareness, do: :auto
+
+ @impl true
+ def filter(%{"type" => type, "object" => child_object} = object)
+ when type in ["Create", "Update"] do
+ scrub_policy = Pleroma.Config.get([:mrf_normalize_markup, :scrub_policy])
+
+ content =
+ child_object["content"]
+ |> HTML.filter_tags(scrub_policy)
+
+ object = put_in(object, ["object", "content"], content)
+
+ {:ok, object}
+ end
+
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_normalize_markup,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.NormalizeMarkup",
+ label: "MRF Normalize Markup",
+ description: "MRF NormalizeMarkup settings. Scrub configured hypertext markup.",
+ children: [
+ %{
+ key: :scrub_policy,
+ type: :module,
+ suggestions: [Pleroma.HTML.Scrubber.Default]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
new file mode 100644
index 0000000..df1a6dc
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/object_age_policy.ex
@@ -0,0 +1,141 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy do
+ alias Pleroma.Config
+ alias Pleroma.User
+
+ require Pleroma.Constants
+
+ @moduledoc "Filter activities depending on their age"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp check_date(%{"object" => %{"published" => published}} = message) do
+ with %DateTime{} = now <- DateTime.utc_now(),
+ {:ok, %DateTime{} = then, _} <- DateTime.from_iso8601(published),
+ max_ttl <- Config.get([:mrf_object_age, :threshold]),
+ {:ttl, false} <- {:ttl, DateTime.diff(now, then) > max_ttl} do
+ {:ok, message}
+ else
+ {:ttl, true} ->
+ {:reject, nil}
+
+ e ->
+ {:error, e}
+ end
+ end
+
+ defp check_reject(message, actions) do
+ if :reject in actions do
+ {:reject, "[ObjectAgePolicy]"}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp check_delist(message, actions) do
+ if :delist in actions do
+ with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do
+ to =
+ List.delete(message["to"] || [], Pleroma.Constants.as_public()) ++
+ [user.follower_address]
+
+ cc =
+ List.delete(message["cc"] || [], user.follower_address) ++
+ [Pleroma.Constants.as_public()]
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Kernel.put_in(["object", "to"], to)
+ |> Kernel.put_in(["object", "cc"], cc)
+
+ {:ok, message}
+ else
+ _e ->
+ {:reject, "[ObjectAgePolicy] Unhandled error"}
+ end
+ else
+ {:ok, message}
+ end
+ end
+
+ defp check_strip_followers(message, actions) do
+ if :strip_followers in actions do
+ with %User{} = user <- User.get_cached_by_ap_id(message["actor"]) do
+ to = List.delete(message["to"] || [], user.follower_address)
+ cc = List.delete(message["cc"] || [], user.follower_address)
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Kernel.put_in(["object", "to"], to)
+ |> Kernel.put_in(["object", "cc"], cc)
+
+ {:ok, message}
+ else
+ _e ->
+ {:reject, "[ObjectAgePolicy] Unhandled error"}
+ end
+ else
+ {:ok, message}
+ end
+ end
+
+ @impl true
+ def filter(%{"type" => "Create", "object" => %{"published" => _}} = message) do
+ with actions <- Config.get([:mrf_object_age, :actions]),
+ {:reject, _} <- check_date(message),
+ {:ok, message} <- check_reject(message, actions),
+ {:ok, message} <- check_delist(message, actions),
+ {:ok, message} <- check_strip_followers(message, actions) do
+ {:ok, message}
+ else
+ # check_date() is allowed to short-circuit the pipeline
+ e -> e
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe do
+ mrf_object_age =
+ Config.get(:mrf_object_age)
+ |> Enum.into(%{})
+
+ {:ok, %{mrf_object_age: mrf_object_age}}
+ end
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_object_age,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.ObjectAgePolicy",
+ label: "MRF Object Age",
+ description:
+ "Rejects or delists posts based on their timestamp deviance from your server's clock.",
+ children: [
+ %{
+ key: :threshold,
+ type: :integer,
+ description: "Required age (in seconds) of a post before actions are taken.",
+ suggestions: [172_800]
+ },
+ %{
+ key: :actions,
+ type: {:list, :atom},
+ description:
+ "A list of actions to apply to the post. `:delist` removes the post from public timelines; " <>
+ "`:strip_followers` removes followers from the ActivityPub recipient list ensuring they won't be delivered to home timelines, additionally for followers-only it degrades to a direct message; " <>
+ "`:reject` rejects the message entirely",
+ suggestions: [:delist, :strip_followers, :reject]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex
new file mode 100644
index 0000000..b2477fe
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/pipeline_filtering.ex
@@ -0,0 +1,7 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.PipelineFiltering do
+ @callback pipeline_filter(map(), keyword()) :: {:ok, map(), keyword()} | {:error, any()}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/policy.ex b/lib/pleroma/web/activity_pub/mrf/policy.ex
new file mode 100644
index 0000000..0234de4
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/policy.ex
@@ -0,0 +1,17 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.Policy do
+ @callback filter(Map.t()) :: {:ok | :reject, Map.t()}
+ @callback describe() :: {:ok | :error, Map.t()}
+ @callback config_description() :: %{
+ optional(:children) => [map()],
+ key: atom(),
+ related_policy: String.t(),
+ label: String.t(),
+ description: String.t()
+ }
+ @callback history_awareness() :: :auto | :manual
+ @optional_callbacks config_description: 0, history_awareness: 0
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex
new file mode 100644
index 0000000..9d4a7a4
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/reject_non_public.ex
@@ -0,0 +1,74 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.RejectNonPublic do
+ @moduledoc "Rejects non-public (followers-only, direct) activities"
+
+ alias Pleroma.Config
+ alias Pleroma.User
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ require Pleroma.Constants
+
+ @impl true
+ def filter(%{"type" => "Create"} = object) do
+ user = User.get_cached_by_ap_id(object["actor"])
+
+ # Determine visibility
+ visibility =
+ cond do
+ Pleroma.Constants.as_public() in object["to"] -> "public"
+ Pleroma.Constants.as_public() in object["cc"] -> "unlisted"
+ user.follower_address in object["to"] -> "followers"
+ true -> "direct"
+ end
+
+ policy = Config.get(:mrf_rejectnonpublic)
+
+ cond do
+ visibility in ["public", "unlisted"] ->
+ {:ok, object}
+
+ visibility == "followers" and Keyword.get(policy, :allow_followersonly) ->
+ {:ok, object}
+
+ visibility == "direct" and Keyword.get(policy, :allow_direct) ->
+ {:ok, object}
+
+ true ->
+ {:reject, "[RejectNonPublic] visibility: #{visibility}"}
+ end
+ end
+
+ @impl true
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe,
+ do: {:ok, %{mrf_rejectnonpublic: Config.get(:mrf_rejectnonpublic) |> Map.new()}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_rejectnonpublic,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.RejectNonPublic",
+ description: "RejectNonPublic drops posts with non-public visibility settings.",
+ label: "MRF Reject Non Public",
+ children: [
+ %{
+ key: :allow_followersonly,
+ label: "Allow followers-only",
+ type: :boolean,
+ description: "Whether to allow followers-only posts"
+ },
+ %{
+ key: :allow_direct,
+ type: :boolean,
+ description: "Whether to allow direct messages"
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/simple_policy.ex b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex
new file mode 100644
index 0000000..829ddea
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/simple_policy.ex
@@ -0,0 +1,370 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.SimplePolicy do
+ @moduledoc "Filter activities depending on their origin instance"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ alias Pleroma.Config
+ alias Pleroma.FollowingRelationship
+ alias Pleroma.User
+ alias Pleroma.Web.ActivityPub.MRF
+
+ require Pleroma.Constants
+
+ defp check_accept(%{host: actor_host} = _actor_info, object) do
+ accepts =
+ instance_list(:accept)
+ |> MRF.subdomains_regex()
+
+ cond do
+ accepts == [] -> {:ok, object}
+ actor_host == Config.get([Pleroma.Web.Endpoint, :url, :host]) -> {:ok, object}
+ MRF.subdomain_match?(accepts, actor_host) -> {:ok, object}
+ true -> {:reject, "[SimplePolicy] host not in accept list"}
+ end
+ end
+
+ defp check_reject(%{host: actor_host} = _actor_info, object) do
+ rejects =
+ instance_list(:reject)
+ |> MRF.subdomains_regex()
+
+ if MRF.subdomain_match?(rejects, actor_host) do
+ {:reject, "[SimplePolicy] host in reject list"}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_media_removal(
+ %{host: actor_host} = _actor_info,
+ %{"type" => type, "object" => %{"attachment" => child_attachment}} = object
+ )
+ when length(child_attachment) > 0 and type in ["Create", "Update"] do
+ media_removal =
+ instance_list(:media_removal)
+ |> MRF.subdomains_regex()
+
+ object =
+ if MRF.subdomain_match?(media_removal, actor_host) do
+ child_object = Map.delete(object["object"], "attachment")
+ Map.put(object, "object", child_object)
+ else
+ object
+ end
+
+ {:ok, object}
+ end
+
+ defp check_media_removal(_actor_info, object), do: {:ok, object}
+
+ defp check_media_nsfw(
+ %{host: actor_host} = _actor_info,
+ %{
+ "type" => type,
+ "object" => %{} = _child_object
+ } = object
+ )
+ when type in ["Create", "Update"] do
+ media_nsfw =
+ instance_list(:media_nsfw)
+ |> MRF.subdomains_regex()
+
+ object =
+ if MRF.subdomain_match?(media_nsfw, actor_host) do
+ Kernel.put_in(object, ["object", "sensitive"], true)
+ else
+ object
+ end
+
+ {:ok, object}
+ end
+
+ defp check_media_nsfw(_actor_info, object), do: {:ok, object}
+
+ defp check_ftl_removal(%{host: actor_host} = _actor_info, object) do
+ timeline_removal =
+ instance_list(:federated_timeline_removal)
+ |> MRF.subdomains_regex()
+
+ object =
+ with true <- MRF.subdomain_match?(timeline_removal, actor_host),
+ user <- User.get_cached_by_ap_id(object["actor"]),
+ true <- Pleroma.Constants.as_public() in object["to"] do
+ to = List.delete(object["to"], Pleroma.Constants.as_public()) ++ [user.follower_address]
+
+ cc = List.delete(object["cc"], user.follower_address) ++ [Pleroma.Constants.as_public()]
+
+ object
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ else
+ _ -> object
+ end
+
+ {:ok, object}
+ end
+
+ defp intersection(list1, list2) do
+ list1 -- list1 -- list2
+ end
+
+ defp check_followers_only(%{host: actor_host} = _actor_info, object) do
+ followers_only =
+ instance_list(:followers_only)
+ |> MRF.subdomains_regex()
+
+ object =
+ with true <- MRF.subdomain_match?(followers_only, actor_host),
+ user <- User.get_cached_by_ap_id(object["actor"]) do
+ # Don't use Map.get/3 intentionally, these must not be nil
+ fixed_to = object["to"] || []
+ fixed_cc = object["cc"] || []
+
+ to = FollowingRelationship.followers_ap_ids(user, fixed_to)
+ cc = FollowingRelationship.followers_ap_ids(user, fixed_cc)
+
+ object
+ |> Map.put("to", intersection([user.follower_address | to], fixed_to))
+ |> Map.put("cc", intersection([user.follower_address | cc], fixed_cc))
+ else
+ _ -> object
+ end
+
+ {:ok, object}
+ end
+
+ defp check_report_removal(%{host: actor_host} = _actor_info, %{"type" => "Flag"} = object) do
+ report_removal =
+ instance_list(:report_removal)
+ |> MRF.subdomains_regex()
+
+ if MRF.subdomain_match?(report_removal, actor_host) do
+ {:reject, "[SimplePolicy] host in report_removal list"}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_report_removal(_actor_info, object), do: {:ok, object}
+
+ defp check_avatar_removal(%{host: actor_host} = _actor_info, %{"icon" => _icon} = object) do
+ avatar_removal =
+ instance_list(:avatar_removal)
+ |> MRF.subdomains_regex()
+
+ if MRF.subdomain_match?(avatar_removal, actor_host) do
+ {:ok, Map.delete(object, "icon")}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_avatar_removal(_actor_info, object), do: {:ok, object}
+
+ defp check_banner_removal(%{host: actor_host} = _actor_info, %{"image" => _image} = object) do
+ banner_removal =
+ instance_list(:banner_removal)
+ |> MRF.subdomains_regex()
+
+ if MRF.subdomain_match?(banner_removal, actor_host) do
+ {:ok, Map.delete(object, "image")}
+ else
+ {:ok, object}
+ end
+ end
+
+ defp check_banner_removal(_actor_info, object), do: {:ok, object}
+
+ defp check_object(%{"object" => object} = activity) do
+ with {:ok, _object} <- filter(object) do
+ {:ok, activity}
+ end
+ end
+
+ defp check_object(object), do: {:ok, object}
+
+ defp instance_list(config_key) do
+ Config.get([:mrf_simple, config_key])
+ |> MRF.instance_list_from_tuples()
+ end
+
+ @impl true
+ def filter(%{"type" => "Delete", "actor" => actor} = object) do
+ %{host: actor_host} = URI.parse(actor)
+
+ reject_deletes =
+ instance_list(:reject_deletes)
+ |> MRF.subdomains_regex()
+
+ if MRF.subdomain_match?(reject_deletes, actor_host) do
+ {:reject, "[SimplePolicy] host in reject_deletes list"}
+ else
+ {:ok, object}
+ end
+ end
+
+ @impl true
+ def filter(%{"actor" => actor} = object) do
+ actor_info = URI.parse(actor)
+
+ with {:ok, object} <- check_accept(actor_info, object),
+ {:ok, object} <- check_reject(actor_info, object),
+ {:ok, object} <- check_media_removal(actor_info, object),
+ {:ok, object} <- check_media_nsfw(actor_info, object),
+ {:ok, object} <- check_ftl_removal(actor_info, object),
+ {:ok, object} <- check_followers_only(actor_info, object),
+ {:ok, object} <- check_report_removal(actor_info, object),
+ {:ok, object} <- check_object(object) do
+ {:ok, object}
+ else
+ {:reject, nil} -> {:reject, "[SimplePolicy]"}
+ {:reject, _} = e -> e
+ _ -> {:reject, "[SimplePolicy]"}
+ end
+ end
+
+ def filter(%{"id" => actor, "type" => obj_type} = object)
+ when obj_type in ["Application", "Group", "Organization", "Person", "Service"] do
+ actor_info = URI.parse(actor)
+
+ with {:ok, object} <- check_accept(actor_info, object),
+ {:ok, object} <- check_reject(actor_info, object),
+ {:ok, object} <- check_avatar_removal(actor_info, object),
+ {:ok, object} <- check_banner_removal(actor_info, object) do
+ {:ok, object}
+ else
+ {:reject, nil} -> {:reject, "[SimplePolicy]"}
+ {:reject, _} = e -> e
+ _ -> {:reject, "[SimplePolicy]"}
+ end
+ end
+
+ def filter(object) when is_binary(object) do
+ uri = URI.parse(object)
+
+ with {:ok, object} <- check_accept(uri, object),
+ {:ok, object} <- check_reject(uri, object) do
+ {:ok, object}
+ else
+ {:reject, nil} -> {:reject, "[SimplePolicy]"}
+ {:reject, _} = e -> e
+ _ -> {:reject, "[SimplePolicy]"}
+ end
+ end
+
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe do
+ exclusions = Config.get([:mrf, :transparency_exclusions]) |> MRF.instance_list_from_tuples()
+
+ mrf_simple_excluded =
+ Config.get(:mrf_simple)
+ |> Enum.map(fn {rule, instances} ->
+ {rule, Enum.reject(instances, fn {host, _} -> host in exclusions end)}
+ end)
+
+ mrf_simple =
+ mrf_simple_excluded
+ |> Enum.map(fn {rule, instances} ->
+ {rule, Enum.map(instances, fn {host, _} -> host end)}
+ end)
+ |> Map.new()
+
+ # This is for backwards compatibility. We originally didn't sent
+ # extra info like a reason why an instance was rejected/quarantined/etc.
+ # Because we didn't want to break backwards compatibility it was decided
+ # to add an extra "info" key.
+ mrf_simple_info =
+ mrf_simple_excluded
+ |> Enum.map(fn {rule, instances} ->
+ {rule, Enum.reject(instances, fn {_, reason} -> reason == "" end)}
+ end)
+ |> Enum.reject(fn {_, instances} -> instances == [] end)
+ |> Enum.map(fn {rule, instances} ->
+ instances =
+ instances
+ |> Enum.map(fn {host, reason} -> {host, %{"reason" => reason}} end)
+ |> Map.new()
+
+ {rule, instances}
+ end)
+ |> Map.new()
+
+ {:ok, %{mrf_simple: mrf_simple, mrf_simple_info: mrf_simple_info}}
+ end
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_simple,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.SimplePolicy",
+ label: "MRF Simple",
+ description: "Simple ingress policies",
+ children:
+ [
+ %{
+ key: :media_removal,
+ description:
+ "List of instances to strip media attachments from and the reason for doing so"
+ },
+ %{
+ key: :media_nsfw,
+ label: "Media NSFW",
+ description:
+ "List of instances to tag all media as NSFW (sensitive) from and the reason for doing so"
+ },
+ %{
+ key: :federated_timeline_removal,
+ description:
+ "List of instances to remove from the Federated (aka The Whole Known Network) Timeline and the reason for doing so"
+ },
+ %{
+ key: :reject,
+ description:
+ "List of instances to reject activities from (except deletes) and the reason for doing so"
+ },
+ %{
+ key: :accept,
+ description:
+ "List of instances to only accept activities from (except deletes) and the reason for doing so"
+ },
+ %{
+ key: :followers_only,
+ description:
+ "Force posts from the given instances to be visible by followers only and the reason for doing so"
+ },
+ %{
+ key: :report_removal,
+ description: "List of instances to reject reports from and the reason for doing so"
+ },
+ %{
+ key: :avatar_removal,
+ description: "List of instances to strip avatars from and the reason for doing so"
+ },
+ %{
+ key: :banner_removal,
+ description: "List of instances to strip banners from and the reason for doing so"
+ },
+ %{
+ key: :reject_deletes,
+ description: "List of instances to reject deletions from and the reason for doing so"
+ }
+ ]
+ |> Enum.map(fn setting ->
+ Map.merge(
+ setting,
+ %{
+ type: {:list, :tuple},
+ key_placeholder: "instance",
+ value_placeholder: "reason",
+ suggestions: [{"example.com", "Some reason"}, {"*.example.com", "Another reason"}]
+ }
+ )
+ end)
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex
new file mode 100644
index 0000000..f66c379
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/steal_emoji_policy.ex
@@ -0,0 +1,154 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy do
+ require Logger
+
+ alias Pleroma.Config
+
+ @moduledoc "Detect new emojis by their shortcode and steals them"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp accept_host?(host), do: host in Config.get([:mrf_steal_emoji, :hosts], [])
+
+ defp shortcode_matches?(shortcode, pattern) when is_binary(pattern) do
+ shortcode == pattern
+ end
+
+ defp shortcode_matches?(shortcode, pattern) do
+ String.match?(shortcode, pattern)
+ end
+
+ defp steal_emoji({shortcode, url}, emoji_dir_path) do
+ url = Pleroma.Web.MediaProxy.url(url)
+
+ with {:ok, %{status: status} = response} when status in 200..299 <- Pleroma.HTTP.get(url) do
+ size_limit = Config.get([:mrf_steal_emoji, :size_limit], 50_000)
+
+ if byte_size(response.body) <= size_limit do
+ extension =
+ url
+ |> URI.parse()
+ |> Map.get(:path)
+ |> Path.basename()
+ |> Path.extname()
+
+ file_path = Path.join(emoji_dir_path, shortcode <> (extension || ".png"))
+
+ case File.write(file_path, response.body) do
+ :ok ->
+ shortcode
+
+ e ->
+ Logger.warn("MRF.StealEmojiPolicy: Failed to write to #{file_path}: #{inspect(e)}")
+ nil
+ end
+ else
+ Logger.debug(
+ "MRF.StealEmojiPolicy: :#{shortcode}: at #{url} (#{byte_size(response.body)} B) over size limit (#{size_limit} B)"
+ )
+
+ nil
+ end
+ else
+ e ->
+ Logger.warn("MRF.StealEmojiPolicy: Failed to fetch #{url}: #{inspect(e)}")
+ nil
+ end
+ end
+
+ @impl true
+ def filter(%{"object" => %{"emoji" => foreign_emojis, "actor" => actor}} = message) do
+ host = URI.parse(actor).host
+
+ if host != Pleroma.Web.Endpoint.host() and accept_host?(host) do
+ installed_emoji = Pleroma.Emoji.get_all() |> Enum.map(fn {k, _} -> k end)
+
+ emoji_dir_path =
+ Config.get(
+ [:mrf_steal_emoji, :path],
+ Path.join(Config.get([:instance, :static_dir]), "emoji/stolen")
+ )
+
+ File.mkdir_p(emoji_dir_path)
+
+ new_emojis =
+ foreign_emojis
+ |> Enum.reject(fn {shortcode, _url} -> shortcode in installed_emoji end)
+ |> Enum.filter(fn {shortcode, _url} ->
+ reject_emoji? =
+ [:mrf_steal_emoji, :rejected_shortcodes]
+ |> Config.get([])
+ |> Enum.find(false, fn pattern -> shortcode_matches?(shortcode, pattern) end)
+
+ !reject_emoji?
+ end)
+ |> Enum.map(&steal_emoji(&1, emoji_dir_path))
+ |> Enum.filter(& &1)
+
+ if !Enum.empty?(new_emojis) do
+ Logger.info("Stole new emojis: #{inspect(new_emojis)}")
+ Pleroma.Emoji.reload()
+ end
+ end
+
+ {:ok, message}
+ end
+
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ @spec config_description :: %{
+ children: [
+ %{
+ description: <<_::272, _::_*256>>,
+ key: :hosts | :rejected_shortcodes | :size_limit,
+ suggestions: [any(), ...],
+ type: {:list, :string} | {:list, :string} | :integer
+ },
+ ...
+ ],
+ description: <<_::448>>,
+ key: :mrf_steal_emoji,
+ label: <<_::80>>,
+ related_policy: <<_::352>>
+ }
+ def config_description do
+ %{
+ key: :mrf_steal_emoji,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.StealEmojiPolicy",
+ label: "MRF Emojis",
+ description: "Steals emojis from selected instances when it sees them.",
+ children: [
+ %{
+ key: :hosts,
+ type: {:list, :string},
+ description: "List of hosts to steal emojis from",
+ suggestions: [""]
+ },
+ %{
+ key: :rejected_shortcodes,
+ type: {:list, :string},
+ description: """
+ A list of patterns or matches to reject shortcodes with.
+
+ Each pattern can be a string or [Regex](https://hexdocs.pm/elixir/Regex.html) in the format of `~r/PATTERN/`.
+ """,
+ suggestions: ["foo", ~r/foo/]
+ },
+ %{
+ key: :size_limit,
+ type: :integer,
+ description: "File size limit (in bytes), checked before an emoji is saved to the disk",
+ suggestions: ["100000"]
+ }
+ ]
+ }
+ end
+
+ @impl true
+ def describe do
+ {:ok, %{}}
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex
new file mode 100644
index 0000000..fdb9e51
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/subchain_policy.ex
@@ -0,0 +1,64 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.SubchainPolicy do
+ alias Pleroma.Config
+ alias Pleroma.Web.ActivityPub.MRF
+
+ require Logger
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp lookup_subchain(actor) do
+ with matches <- Config.get([:mrf_subchain, :match_actor]),
+ {match, subchain} <- Enum.find(matches, fn {k, _v} -> String.match?(actor, k) end) do
+ {:ok, match, subchain}
+ else
+ _e -> {:error, :notfound}
+ end
+ end
+
+ @impl true
+ def filter(%{"actor" => actor} = message) do
+ with {:ok, match, subchain} <- lookup_subchain(actor) do
+ Logger.debug(
+ "[SubchainPolicy] Matched #{actor} against #{inspect(match)} with subchain #{inspect(subchain)}"
+ )
+
+ MRF.filter(subchain, message)
+ else
+ _e -> {:ok, message}
+ end
+ end
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_subchain,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.SubchainPolicy",
+ label: "MRF Subchain",
+ description:
+ "This policy processes messages through an alternate pipeline when a given message matches certain criteria." <>
+ " All criteria are configured as a map of regular expressions to lists of policy modules.",
+ children: [
+ %{
+ key: :match_actor,
+ type: {:map, {:list, :string}},
+ description: "Matches a series of regular expressions against the actor field",
+ suggestions: [
+ %{
+ ~r/https:\/\/example.com/s => [Pleroma.Web.ActivityPub.MRF.DropPolicy]
+ }
+ ]
+ }
+ ]
+ }
+ end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/tag_policy.ex b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex
new file mode 100644
index 0000000..73760ca
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/tag_policy.ex
@@ -0,0 +1,163 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.TagPolicy do
+ alias Pleroma.User
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+ @moduledoc """
+ Apply policies based on user tags
+
+ This policy applies policies on a user activities depending on their tags
+ on your instance.
+
+ - `mrf_tag:media-force-nsfw`: Mark as sensitive on presence of attachments
+ - `mrf_tag:media-strip`: Remove attachments
+ - `mrf_tag:force-unlisted`: Mark as unlisted (removes from the federated timeline)
+ - `mrf_tag:sandbox`: Remove from public (local and federated) timelines
+ - `mrf_tag:disable-remote-subscription`: Reject non-local follow requests
+ - `mrf_tag:disable-any-subscription`: Reject any follow requests
+ """
+
+ require Pleroma.Constants
+
+ defp get_tags(%User{tags: tags}) when is_list(tags), do: tags
+ defp get_tags(_), do: []
+
+ defp process_tag(
+ "mrf_tag:media-force-nsfw",
+ %{
+ "type" => type,
+ "object" => %{"attachment" => child_attachment}
+ } = message
+ )
+ when length(child_attachment) > 0 and type in ["Create", "Update"] do
+ {:ok, Kernel.put_in(message, ["object", "sensitive"], true)}
+ end
+
+ defp process_tag(
+ "mrf_tag:media-strip",
+ %{
+ "type" => type,
+ "object" => %{"attachment" => child_attachment} = object
+ } = message
+ )
+ when length(child_attachment) > 0 and type in ["Create", "Update"] do
+ object = Map.delete(object, "attachment")
+ message = Map.put(message, "object", object)
+
+ {:ok, message}
+ end
+
+ defp process_tag(
+ "mrf_tag:force-unlisted",
+ %{
+ "type" => "Create",
+ "to" => to,
+ "cc" => cc,
+ "actor" => actor,
+ "object" => object
+ } = message
+ ) do
+ user = User.get_cached_by_ap_id(actor)
+
+ if Enum.member?(to, Pleroma.Constants.as_public()) do
+ to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
+ cc = List.delete(cc, user.follower_address) ++ [Pleroma.Constants.as_public()]
+
+ object =
+ object
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Map.put("object", object)
+
+ {:ok, message}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp process_tag(
+ "mrf_tag:sandbox",
+ %{
+ "type" => "Create",
+ "to" => to,
+ "cc" => cc,
+ "actor" => actor,
+ "object" => object
+ } = message
+ ) do
+ user = User.get_cached_by_ap_id(actor)
+
+ if Enum.member?(to, Pleroma.Constants.as_public()) or
+ Enum.member?(cc, Pleroma.Constants.as_public()) do
+ to = List.delete(to, Pleroma.Constants.as_public()) ++ [user.follower_address]
+ cc = List.delete(cc, Pleroma.Constants.as_public())
+
+ object =
+ object
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+
+ message =
+ message
+ |> Map.put("to", to)
+ |> Map.put("cc", cc)
+ |> Map.put("object", object)
+
+ {:ok, message}
+ else
+ {:ok, message}
+ end
+ end
+
+ defp process_tag(
+ "mrf_tag:disable-remote-subscription",
+ %{"type" => "Follow", "actor" => actor} = message
+ ) do
+ user = User.get_cached_by_ap_id(actor)
+
+ if user.local == true do
+ {:ok, message}
+ else
+ {:reject,
+ "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-remote-subscription"}
+ end
+ end
+
+ defp process_tag("mrf_tag:disable-any-subscription", %{"type" => "Follow", "actor" => actor}),
+ do: {:reject, "[TagPolicy] Follow from #{actor} tagged with mrf_tag:disable-any-subscription"}
+
+ defp process_tag(_, message), do: {:ok, message}
+
+ def filter_message(actor, message) do
+ User.get_cached_by_ap_id(actor)
+ |> get_tags()
+ |> Enum.reduce({:ok, message}, fn
+ tag, {:ok, message} ->
+ process_tag(tag, message)
+
+ _, error ->
+ error
+ end)
+ end
+
+ @impl true
+ def filter(%{"object" => target_actor, "type" => "Follow"} = message),
+ do: filter_message(target_actor, message)
+
+ @impl true
+ def filter(%{"actor" => actor, "type" => type} = message) when type in ["Create", "Update"],
+ do: filter_message(actor, message)
+
+ @impl true
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe, do: {:ok, %{}}
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex
new file mode 100644
index 0000000..e14047d
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex
@@ -0,0 +1,65 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy do
+ alias Pleroma.Config
+
+ @moduledoc "Accept-list of users from specified instances"
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ defp filter_by_list(object, []), do: {:ok, object}
+
+ defp filter_by_list(%{"actor" => actor} = object, allow_list) do
+ if actor in allow_list do
+ {:ok, object}
+ else
+ {:reject, "[UserAllowListPolicy] #{actor} not in the list"}
+ end
+ end
+
+ @impl true
+ def filter(%{"actor" => actor} = object) do
+ actor_info = URI.parse(actor)
+
+ allow_list =
+ Config.get(
+ [:mrf_user_allowlist, actor_info.host],
+ []
+ )
+
+ filter_by_list(object, allow_list)
+ end
+
+ def filter(object), do: {:ok, object}
+
+ @impl true
+ def describe do
+ mrf_user_allowlist =
+ Config.get([:mrf_user_allowlist], [])
+ |> Map.new(fn {k, v} -> {k, length(v)} end)
+
+ {:ok, %{mrf_user_allowlist: mrf_user_allowlist}}
+ end
+
+ # TODO: change way of getting settings on `lib/pleroma/web/activity_pub/mrf/user_allow_list_policy.ex:18` to use `hosts` subkey
+ # @impl true
+ # def config_description do
+ # %{
+ # key: :mrf_user_allowlist,
+ # related_policy: "Pleroma.Web.ActivityPub.MRF.UserAllowListPolicy",
+ # description: "Accept-list of users from specified instances",
+ # children: [
+ # %{
+ # key: :hosts,
+ # type: :map,
+ # description:
+ # "The keys in this section are the domain names that the policy should apply to." <>
+ # " Each key should be assigned a list of users that should be allowed " <>
+ # "through by their ActivityPub ID",
+ # suggestions: [%{"example.org" => ["https://example.org/users/admin"]}]
+ # }
+ # ]
+ # }
+ # end
+end
diff --git a/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex
new file mode 100644
index 0000000..d9deff3
--- /dev/null
+++ b/lib/pleroma/web/activity_pub/mrf/vocabulary_policy.ex
@@ -0,0 +1,69 @@
+# Pleroma: A lightweight social networking server
+# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
+# SPDX-License-Identifier: AGPL-3.0-only
+
+defmodule Pleroma.Web.ActivityPub.MRF.VocabularyPolicy do
+ @moduledoc "Filter messages which belong to certain activity vocabularies"
+
+ @behaviour Pleroma.Web.ActivityPub.MRF.Policy
+
+ @impl true
+ def filter(%{"type" => "Undo", "object" => child_message} = message) do
+ with {:ok, _} <- filter(child_message) do
+ {:ok, message}
+ else
+ {:reject, _} = e -> e
+ end
+ end
+
+ def filter(%{"type" => message_type} = message) do
+ with accepted_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :accept]),
+ rejected_vocabulary <- Pleroma.Config.get([:mrf_vocabulary, :reject]),
+ {_, true} <-
+ {:accepted,
+ Enum.empty?(accepted_vocabulary) || Enum.member?(accepted_vocabulary, message_type)},
+ {_, false} <-
+ {:rejected,
+ length(rejected_vocabulary) > 0 && Enum.member?(rejected_vocabulary, message_type)},
+ {:ok, _} <- filter(message["object"]) do
+ {:ok, message}
+ else
+ {:reject, _} = e -> e
+ {:accepted, _} -> {:reject, "[VocabularyPolicy] #{message_type} not in accept list"}
+ {:rejected, _} -> {:reject, "[VocabularyPolicy] #{message_type} in reject list"}
+ _ -> {:reject, "[VocabularyPolicy]"}
+ end
+ end
+
+ def filter(message), do: {:ok, message}
+
+ @impl true
+ def describe,
+ do: {:ok, %{mrf_vocabulary: Pleroma.Config.get(:mrf_vocabulary) |> Map.new()}}
+
+ @impl true
+ def config_description do
+ %{
+ key: :mrf_vocabulary,
+ related_policy: "Pleroma.Web.ActivityPub.MRF.VocabularyPolicy",
+ label: "MRF Vocabulary",
+ description: "Filter messages which belong to certain activity vocabularies",
+ children: [
+ %{
+ key: :accept,
+ type: {:list, :string},
+ description:
+ "A list of ActivityStreams terms to accept. If empty, all supported messages are accepted.",
+ suggestions: ["Create", "Follow", "Mention", "Announce", "Like"]
+ },
+ %{
+ key: :reject,
+ type: {:list, :string},
+ description:
+ "A list of ActivityStreams terms to reject. If empty, no messages are rejected.",
+ suggestions: ["Create", "Follow", "Mention", "Announce", "Like"]
+ }
+ ]
+ }
+ end
+end