refactor(anti-spam): make anti-spam agnostic from Akismet

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel 2023-06-01 14:48:42 +02:00
parent 1798acc3c0
commit 618b3d23d9
No known key found for this signature in database
GPG key ID: A061B9DDE0CA0773
13 changed files with 150 additions and 27 deletions

View file

@ -378,6 +378,8 @@ config :mobilizon, Mobilizon.Service.GlobalSearch.SearchMobilizon,
img_src: ["search.joinmobilizon.org"] img_src: ["search.joinmobilizon.org"]
] ]
config :mobilizon, Mobilizon.Service.AntiSpam, service: Mobilizon.Service.AntiSpam.Akismet
# Import environment specific config. This must remain at the bottom # Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above. # of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs" import_config "#{config_env()}.exs"

View file

@ -90,6 +90,8 @@ config :junit_formatter, report_dir: "."
config :mobilizon, :http_security, report_uri: "https://endpoint.com" config :mobilizon, :http_security, report_uri: "https://endpoint.com"
config :mobilizon, Mobilizon.Service.AntiSpam, service: Mobilizon.Service.AntiSpam.Mock
if System.get_env("DOCKER", "false") == "false" && File.exists?("./config/test.secret.exs") do if System.get_env("DOCKER", "false") == "false" && File.exists?("./config/test.secret.exs") do
import_config "test.secret.exs" import_config "test.secret.exs"
end end

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.API.Reports do
alias Mobilizon.Federation.ActivityPub.{Actions, Activity} alias Mobilizon.Federation.ActivityPub.{Actions, Activity}
alias Mobilizon.Reports, as: ReportsAction alias Mobilizon.Reports, as: ReportsAction
alias Mobilizon.Reports.{Note, Report, ReportStatus} alias Mobilizon.Reports.{Note, Report, ReportStatus}
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam.Akismet
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Web.Gettext, only: [dgettext: 2] import Mobilizon.Web.Gettext, only: [dgettext: 2]
require Logger require Logger

View file

@ -7,7 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment, as: CommentModel alias Mobilizon.Discussions.Comment, as: CommentModel
alias Mobilizon.Events.{Event, EventOptions} alias Mobilizon.Events.{Event, EventOptions}
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@ -45,14 +45,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
if comment_moderation != :closed || actor_id == organizer_actor_id do if comment_moderation != :closed || actor_id == organizer_actor_id do
args = Map.put(args, :actor_id, actor_id) args = Map.put(args, :actor_id, actor_id)
if Akismet.check_comment( if AntiSpam.service().check_comment(
args.text, args.text,
preferred_username, preferred_username,
!is_nil(Map.get(args, :in_reply_to_comment_id)), !is_nil(Map.get(args, :in_reply_to_comment_id)),
email, email,
current_ip, current_ip,
user_agent user_agent
) do ) == :ham do
do_create_comment(args) do_create_comment(args)
else else
{:error, {:error,

View file

@ -5,7 +5,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
alias Mobilizon.Config alias Mobilizon.Config
alias Mobilizon.Events.Categories alias Mobilizon.Events.Categories
alias Mobilizon.Service.{Akismet, FrontEndAnalytics} alias Mobilizon.Service.{AntiSpam, FrontEndAnalytics}
@doc """ @doc """
Gets config. Gets config.
@ -146,7 +146,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Config do
features: %{ features: %{
groups: Config.instance_group_feature_enabled?(), groups: Config.instance_group_feature_enabled?(),
event_creation: Config.instance_event_creation_enabled?(), event_creation: Config.instance_event_creation_enabled?(),
antispam: Akismet.ready?() antispam: AntiSpam.service().ready?()
}, },
restrictions: %{ restrictions: %{
only_admin_can_create_groups: Config.only_admin_can_create_groups?(), only_admin_can_create_groups: Config.only_admin_can_create_groups?(),

View file

@ -13,7 +13,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
alias Mobilizon.Federation.ActivityPub.Activity alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.Federation.ActivityPub.Permission alias Mobilizon.Federation.ActivityPub.Permission
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam
alias Mobilizon.Service.TimezoneDetector alias Mobilizon.Service.TimezoneDetector
import Mobilizon.Users.Guards, only: [is_moderator: 1] import Mobilizon.Users.Guards, only: [is_moderator: 1]
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@ -260,7 +260,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
args |> Map.put(:organizer_actor, organizer_actor) |> extract_timezone(user.id), args |> Map.put(:organizer_actor, organizer_actor) |> extract_timezone(user.id),
{:askismet, :ham} <- {:askismet, :ham} <-
{:askismet, {:askismet,
Akismet.check_event( AntiSpam.service().check_event(
args.description, args.description,
organizer_actor.preferred_username, organizer_actor.preferred_username,
email, email,

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
alias Mobilizon.{Actors, Events, Users} alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Follower, Member} alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.Participant alias Mobilizon.Events.Participant
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User alias Mobilizon.Users.User
import Mobilizon.Web.Gettext import Mobilizon.Web.Gettext
@ -133,18 +133,20 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
def create_person( def create_person(
_parent, _parent,
%{preferred_username: _preferred_username} = args, %{preferred_username: _preferred_username} = args,
%{context: %{current_user: user}} = _resolution %{context: %{current_user: user} = context} = _resolution
) do ) do
args = Map.put(args, :user_id, user.id) args = Map.put(args, :user_id, user.id)
user_agent = Map.get(context, :user_agent, "")
with args <- Map.update(args, :preferred_username, "", &String.downcase/1), with args <- Map.update(args, :preferred_username, "", &String.downcase/1),
{:akismet, :ham} <- {:spam, :ham} <-
{:akismet, {:spam,
Akismet.check_profile( AntiSpam.service().check_profile(
args.preferred_username, args.preferred_username,
args.summary, args.summary,
user.email, user.email,
user.current_sign_in_ip user.current_sign_in_ip,
user_agent
)}, )},
{:picture, args} when is_map(args) <- {:picture, save_attached_pictures(args)}, {:picture, args} when is_map(args) <- {:picture, save_attached_pictures(args)},
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do {:ok, %Actor{} = new_person} <- Actors.new_person(args) do
@ -308,9 +310,9 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
{:ok, %User{} = user} -> {:ok, %User{} = user} ->
if is_nil(Users.get_actor_for_user(user)) do if is_nil(Users.get_actor_for_user(user)) do
# No profile yet, we can create one # No profile yet, we can create one
with {:akismet, :ham} <- with {:spam, :ham} <-
{:akismet, {:spam,
Akismet.check_profile( AntiSpam.service().check_profile(
args.preferred_username, args.preferred_username,
args.summary, args.summary,
args.email, args.email,
@ -326,7 +328,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
{:error, _err} -> {:error, _err} ->
{:error, dgettext("errors", "Error while uploading pictures")} {:error, dgettext("errors", "Error while uploading pictures")}
{:akismet, _} -> {:spam, _} ->
{:error, dgettext("errors", "Your profile was detected as spam.")} {:error, dgettext("errors", "Your profile was detected as spam.")}
end end
else else

View file

@ -8,7 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
alias Mobilizon.{Actors, Admin, Config, Events, FollowedGroupActivity, Users} alias Mobilizon.{Actors, Admin, Config, Events, FollowedGroupActivity, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Federation.ActivityPub.Actions alias Mobilizon.Federation.ActivityPub.Actions
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam
alias Mobilizon.Service.Auth.Authenticator alias Mobilizon.Service.Auth.Authenticator
alias Mobilizon.Storage.{Page, Repo} alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.{Setting, User} alias Mobilizon.Users.{Setting, User}
@ -161,8 +161,8 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
with {:ok, email} <- lowercase_domain(email), with {:ok, email} <- lowercase_domain(email),
:registration_ok <- check_registration_config(email), :registration_ok <- check_registration_config(email),
:not_deny_listed <- check_registration_denylist(email), :not_deny_listed <- check_registration_denylist(email),
{:akismet, :ham} <- {:spam, :ham} <-
{:akismet, Akismet.check_user(email, current_ip, user_agent)}, {:spam, AntiSpam.service().check_user(email, current_ip, user_agent)},
{:ok, %User{} = user} <- {:ok, %User{} = user} <-
args args
|> Map.merge(%{email: email, current_sign_in_ip: current_ip, current_sign_in_at: now}) |> Map.merge(%{email: email, current_sign_in_ip: current_ip, current_sign_in_at: now})
@ -186,7 +186,7 @@ defmodule Mobilizon.GraphQL.Resolvers.User do
"Your e-mail has been denied registration or uses a disallowed e-mail provider" "Your e-mail has been denied registration or uses a disallowed e-mail provider"
)} )}
{:akismet, _} -> {:spam, _} ->
{:error, {:error,
dgettext( dgettext(
"errors", "errors",

View file

@ -6,7 +6,7 @@ defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
alias Mobilizon.{Actors, Config, Events, Users} alias Mobilizon.{Actors, Config, Events, Users}
alias Mobilizon.Actors.Actor alias Mobilizon.Actors.Actor
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Service.Akismet alias Mobilizon.Service.AntiSpam
import Mix.Tasks.Mobilizon.Common import Mix.Tasks.Mobilizon.Common
alias Mobilizon.Federation.ActivityPub.Actions alias Mobilizon.Federation.ActivityPub.Actions
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
@ -35,7 +35,7 @@ defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
start_mobilizon() start_mobilizon()
unless Akismet.ready?() do unless anti_spam().ready?() do
shell_error("Akismet is missing an API key in the configuration") shell_error("Akismet is missing an API key in the configuration")
end end
@ -79,7 +79,7 @@ defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
email = if(is_nil(user), do: nil, else: user.email) email = if(is_nil(user), do: nil, else: user.email)
ip = if(is_nil(user), do: nil, else: user.current_sign_in_ip || user.last_sign_in_ip) ip = if(is_nil(user), do: nil, else: user.current_sign_in_ip || user.last_sign_in_ip)
case Akismet.check_profile(preferred_username, summary, email, ip) do case anti_spam().check_profile(preferred_username, summary, email, ip, nil) do
res when res in [:spam, :discard] -> res when res in [:spam, :discard] ->
handle_spam_profile(preferred_username, id, options) handle_spam_profile(preferred_username, id, options)
@ -113,7 +113,13 @@ defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
{nil, nil} {nil, nil}
end end
case Akismet.check_event(event_description, organizer_actor.preferred_username, email, ip) do case anti_spam().check_event(
event_description,
organizer_actor.preferred_username,
email,
ip,
nil
) do
res when res in [:spam, :discard] -> res when res in [:spam, :discard] ->
handle_spam_event(event_id, title, uuid, organizer_actor.id, options) handle_spam_event(event_id, title, uuid, organizer_actor.id, options)
@ -174,4 +180,6 @@ defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
defp verbose?(options), do: Keyword.get(options, :verbose, false) defp verbose?(options), do: Keyword.get(options, :verbose, false)
defp dry_run?(options), do: Keyword.get(options, :dry_run, false) defp dry_run?(options), do: Keyword.get(options, :dry_run, false)
defp anti_spam, do: AntiSpam.service()
end end

View file

@ -1,4 +1,4 @@
defmodule Mobilizon.Service.Akismet do defmodule Mobilizon.Service.AntiSpam.Akismet do
@moduledoc """ @moduledoc """
Validate user data Validate user data
""" """
@ -9,12 +9,16 @@ defmodule Mobilizon.Service.Akismet do
alias Mobilizon.Discussions.Comment alias Mobilizon.Discussions.Comment
alias Mobilizon.Events.Event alias Mobilizon.Events.Event
alias Mobilizon.Reports.Report alias Mobilizon.Reports.Report
alias Mobilizon.Service.AntiSpam.Provider
alias Mobilizon.Users.User alias Mobilizon.Users.User
alias Mobilizon.Web.Endpoint alias Mobilizon.Web.Endpoint
require Logger require Logger
@behaviour Provider
@env Application.compile_env(:mobilizon, :env) @env Application.compile_env(:mobilizon, :env)
@impl Provider
@spec check_user(String.t(), String.t(), String.t()) :: @spec check_user(String.t(), String.t(), String.t()) ::
:ham | :spam | :discard | {:error, HTTPoison.Response.t()} :ham | :spam | :discard | {:error, HTTPoison.Response.t()}
def check_user(email, ip, user_agent) do def check_user(email, ip, user_agent) do
@ -27,6 +31,7 @@ defmodule Mobilizon.Service.Akismet do
}) })
end end
@impl Provider
@spec check_profile(String.t(), String.t(), String.t() | nil, String.t(), String.t()) :: @spec check_profile(String.t(), String.t(), String.t() | nil, String.t(), String.t()) ::
:ham | :spam | :discard | {:error, HTTPoison.Response.t()} :ham | :spam | :discard | {:error, HTTPoison.Response.t()}
def check_profile(username, summary, email \\ nil, ip \\ "127.0.0.1", user_agent \\ nil) do def check_profile(username, summary, email \\ nil, ip \\ "127.0.0.1", user_agent \\ nil) do
@ -41,6 +46,7 @@ defmodule Mobilizon.Service.Akismet do
}) })
end end
@impl Provider
@spec check_event(String.t(), String.t(), String.t() | nil, String.t(), String.t()) :: @spec check_event(String.t(), String.t(), String.t() | nil, String.t(), String.t()) ::
:ham | :spam | :discard | {:error, HTTPoison.Response.t()} :ham | :spam | :discard | {:error, HTTPoison.Response.t()}
def check_event(event_body, username, email \\ nil, ip \\ "127.0.0.1", user_agent \\ nil) do def check_event(event_body, username, email \\ nil, ip \\ "127.0.0.1", user_agent \\ nil) do
@ -55,6 +61,7 @@ defmodule Mobilizon.Service.Akismet do
}) })
end end
@impl Provider
@spec check_comment(String.t(), String.t(), boolean(), String.t() | nil, String.t(), String.t()) :: @spec check_comment(String.t(), String.t(), boolean(), String.t() | nil, String.t(), String.t()) ::
:ham | :spam | :discard | {:error, HTTPoison.Response.t()} :ham | :spam | :discard | {:error, HTTPoison.Response.t()}
def check_comment( def check_comment(
@ -108,6 +115,7 @@ defmodule Mobilizon.Service.Akismet do
Application.get_env(:mobilizon, __MODULE__) |> get_in([:key]) Application.get_env(:mobilizon, __MODULE__) |> get_in([:key])
end end
@impl Provider
def ready?, do: !is_nil(api_key()) def ready?, do: !is_nil(api_key())
@spec report_to_akismet_comment(Report.t()) :: AkismetComment.t() | {:error, atom()} @spec report_to_akismet_comment(Report.t()) :: AkismetComment.t() | {:error, atom()}

View file

@ -0,0 +1,17 @@
defmodule Mobilizon.Service.AntiSpam do
@moduledoc """
Module to load the service adapter defined inside the configuration.
See `Mobilizon.Service.AntiSpam.Provider`.
"""
@doc """
Returns the appropriate service adapter.
According to the config behind
`config :mobilizon, Mobilizon.Service.AntiSpam,
service: Mobilizon.Service.AntiSpam.Module`
"""
@spec service :: module
def service, do: get_in(Application.get_env(:mobilizon, __MODULE__), [:service])
end

View file

@ -0,0 +1,58 @@
defmodule Mobilizon.Service.AntiSpam.Provider do
@moduledoc """
Provider Behaviour for anti-spam detection.
## Supported backends
* `Mobilizon.Service.AntiSpam.Akismet` [🔗](https://akismet.com/)
"""
@type spam_result :: :ham | :spam | :discard
@type result :: spam_result() | {:error, any()}
@doc """
Make sure the provider is ready
"""
@callback ready?() :: boolean()
@doc """
Check an user details
"""
@callback check_user(email :: String.t(), ip :: String.t(), user_agent :: String.t()) ::
result()
@doc """
Check a profile details
"""
@callback check_profile(
username :: String.t(),
summary :: String.t(),
email :: String.t() | nil,
ip :: String.t(),
user_agent :: String.t() | nil
) :: result()
@doc """
Check an event details
"""
@callback check_event(
event_body :: String.t(),
username :: String.t(),
email :: String.t() | nil,
ip :: String.t(),
user_agent :: String.t() | nil
) :: result()
@doc """
Check a comment details
"""
@callback check_comment(
comment_body :: String.t(),
username :: String.t(),
is_reply? :: boolean(),
email :: String.t() | nil,
ip :: String.t(),
user_agent :: String.t() | nil
) :: result()
end

View file

@ -0,0 +1,26 @@
defmodule Mobilizon.Service.AntiSpam.Mock do
@moduledoc """
Mock for Anti-spam Provider implementations.
"""
alias Mobilizon.Service.AntiSpam.Provider
@behaviour Provider
@impl Provider
def ready?, do: true
@impl Provider
def check_user(_email, _ip, _user_agent), do: :ham
@impl Provider
def check_profile("spam", _summary, _email, _ip, _user_agent), do: :spam
def check_profile(_preferred_username, _summary, _email, _ip, _user_agent), do: :ham
@impl Provider
def check_event("some spam event", _username, _email, _ip, _user_agent), do: :spam
def check_event(_event_body, _username, _email, _ip, _user_agent), do: :ham
@impl Provider
def check_comment(_comment_body, _username, _is_reply?, _email, _ip, _user_agent), do: :ham
end