feat(spam): Introduce checking new accounts, events & comments for spam with the help of Akismet

Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
Thomas Citharel
2023-01-31 19:35:29 +01:00
parent 1db5c4ae2d
commit 317a3434b2
83 changed files with 7186 additions and 2394 deletions

View File

@@ -5,11 +5,13 @@ defmodule Mobilizon.GraphQL.API.Reports do
alias Mobilizon.Actors.Actor
alias Mobilizon.{Admin, Users}
alias Mobilizon.Federation.ActivityPub.{Actions, Activity}
alias Mobilizon.Reports, as: ReportsAction
alias Mobilizon.Reports.{Note, Report, ReportStatus}
alias Mobilizon.Service.Akismet
alias Mobilizon.Users.User
alias Mobilizon.Federation.ActivityPub.{Actions, Activity}
import Mobilizon.Web.Gettext, only: [dgettext: 2]
require Logger
@doc """
Create a report/flag on an actor, and optionally on an event or on comments.
@@ -20,18 +22,48 @@ defmodule Mobilizon.GraphQL.API.Reports do
end
@doc """
Update the state of a report
Update the status of a report
"""
@spec update_report_status(Actor.t(), Report.t(), atom()) ::
@spec update_report_status(Actor.t(), Report.t(), atom(), atom() | nil) ::
{:ok, Report.t()} | {:error, Ecto.Changeset.t() | String.t()}
def update_report_status(%Actor{} = actor, %Report{} = report, state) do
if ReportStatus.valid_value?(state) do
with {:ok, %Report{} = report} <- ReportsAction.update_report(report, %{"status" => state}) do
Admin.log_action(actor, "update", report)
def update_report_status(
%Actor{} = actor,
%Report{status: old_status} = report,
status,
antispam_feedback \\ nil
) do
if ReportStatus.valid_value?(status) do
with {:ok, %Report{} = report} <-
ReportsAction.update_report(report, %{
"status" => status,
"antispam_feedback" => antispam_feedback
}) do
if old_status != status do
Admin.log_action(actor, "update", report)
end
antispam_response =
case antispam_feedback do
:ham ->
Logger.debug("Reporting a report details as ham")
Akismet.report_ham(report)
:spam ->
Logger.debug("Reporting a report details as spam")
Akismet.report_spam(report)
_ ->
:ok
end
if antispam_response != :ok do
Logger.warn("Antispam response has been #{inspect(antispam_response)}")
end
{:ok, report}
end
else
{:error, "Unsupported state"}
{:error, dgettext("errors", "Unsupported status for a report")}
end
end
@@ -58,7 +90,11 @@ defmodule Mobilizon.GraphQL.API.Reports do
{:ok, note}
end
else
{:error, "You need to be a moderator or an administrator to create a note on a report"}
{:error,
dgettext(
"errors",
"You need to be a moderator or an administrator to create a note on a report"
)}
end
end
@@ -81,10 +117,14 @@ defmodule Mobilizon.GraphQL.API.Reports do
{:ok, note}
end
else
{:error, "You need to be a moderator or an administrator to create a note on a report"}
{:error,
dgettext(
"errors",
"You need to be a moderator or an administrator to create a note on a report"
)}
end
else
{:error, "You can only remove your own notes"}
{:error, dgettext("errors", "You can only remove your own notes")}
end
end
end

View File

@@ -7,6 +7,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
alias Mobilizon.Actors.Actor
alias Mobilizon.Discussions.Comment, as: CommentModel
alias Mobilizon.Events.{Event, EventOptions}
alias Mobilizon.Service.Akismet
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext
@@ -25,11 +26,16 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
_parent,
%{event_id: event_id} = args,
%{
context: %{
current_actor: %Actor{id: actor_id}
}
context:
%{
current_actor: %Actor{id: actor_id, preferred_username: preferred_username},
current_user: %User{email: email}
} = context
}
) do
current_ip = Map.get(context, :ip)
user_agent = Map.get(context, :user_agent, "")
case Events.get_event(event_id) do
{:ok,
%Event{
@@ -39,12 +45,27 @@ defmodule Mobilizon.GraphQL.Resolvers.Comment do
if comment_moderation != :closed || actor_id == organizer_actor_id do
args = Map.put(args, :actor_id, actor_id)
case Comments.create_comment(args) do
{:ok, _, %CommentModel{} = comment} ->
{:ok, comment}
if Akismet.check_comment(
args.text,
preferred_username,
!is_nil(Map.get(args, :in_reply_to_comment_id)),
email,
current_ip,
user_agent
) do
case Comments.create_comment(args) do
{:ok, _, %CommentModel{} = comment} ->
{:ok, comment}
{:error, err} ->
{:error, err}
{:error, err} ->
{:error, err}
end
else
{:error,
dgettext(
"errors",
"This comment was detected as spam."
)}
end
else
{:error, :unauthorized}

View File

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

View File

@@ -13,6 +13,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
alias Mobilizon.Federation.ActivityPub.Activity
alias Mobilizon.Federation.ActivityPub.Permission
alias Mobilizon.Service.Akismet
alias Mobilizon.Service.TimezoneDetector
import Mobilizon.Users.Guards, only: [is_moderator: 1]
import Mobilizon.Web.Gettext
@@ -246,48 +247,65 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
def create_event(
_parent,
%{organizer_actor_id: organizer_actor_id} = args,
%{context: %{current_user: %User{} = user}} = _resolution
%{context: %{current_user: %User{email: email} = user} = context} = _resolution
) do
case User.owns_actor(user, organizer_actor_id) do
{:is_owned, %Actor{} = organizer_actor} ->
if can_create_event?(args) do
if is_organizer_group_member?(args) do
args_with_organizer =
args |> Map.put(:organizer_actor, organizer_actor) |> extract_timezone(user.id)
case API.Events.create_event(args_with_organizer) do
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} ->
{:ok, event}
{:error, %Ecto.Changeset{} = error} ->
{:error, error}
{:error, err} ->
Logger.warning("Unknown error while creating event: #{inspect(err)}")
{:error,
dgettext(
"errors",
"Unknown error while creating event"
)}
end
else
{:error,
dgettext(
"errors",
"Organizer profile doesn't have permission to create an event on behalf of this group"
)}
end
else
{:error,
dgettext(
"errors",
"Only groups can create events"
)}
end
current_ip = Map.get(context, :ip)
user_agent = Map.get(context, :user_agent, "")
with {:is_owned, %Actor{} = organizer_actor} <- User.owns_actor(user, organizer_actor_id),
{:can_create_event, true} <- can_create_event(args),
{:organizer_group_member, true} <-
{:organizer_group_member, is_organizer_group_member?(args)},
args_with_organizer <-
args |> Map.put(:organizer_actor, organizer_actor) |> extract_timezone(user.id),
{:askismet, :ham} <-
{:askismet,
Akismet.check_event(
args.description,
organizer_actor.preferred_username,
email,
current_ip,
user_agent
)},
{:ok, %Activity{data: %{"object" => %{"type" => "Event"}}}, %Event{} = event} <-
API.Events.create_event(args_with_organizer) do
{:ok, event}
else
{:is_owned, nil} ->
{:error, dgettext("errors", "Organizer profile is not owned by the user")}
{:can_create_event, false} ->
{:error,
dgettext(
"errors",
"Only groups can create events"
)}
{:organizer_group_member, false} ->
{:error,
dgettext(
"errors",
"Organizer profile doesn't have permission to create an event on behalf of this group"
)}
{:askismet, _} ->
{:error,
dgettext(
"errors",
"This event was detected as spam."
)}
{:error, %Ecto.Changeset{} = error} ->
{:error, error}
{:error, err} ->
Logger.warning("Unknown error while creating event: #{inspect(err)}")
{:error,
dgettext(
"errors",
"Unknown error while creating event"
)}
end
end
@@ -295,12 +313,12 @@ defmodule Mobilizon.GraphQL.Resolvers.Event do
{:error, dgettext("errors", "You need to be logged-in to create events")}
end
@spec can_create_event?(map()) :: boolean()
defp can_create_event?(args) do
@spec can_create_event(map()) :: {:can_create_event, boolean()}
defp can_create_event(args) do
if Config.only_groups_can_create_events?() do
Map.get(args, :attributed_to_id) != nil
{:can_create_event, Map.get(args, :attributed_to_id) != nil}
else
true
{:can_create_event, true}
end
end

View File

@@ -8,6 +8,7 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
alias Mobilizon.{Actors, Events, Users}
alias Mobilizon.Actors.{Actor, Follower, Member}
alias Mobilizon.Events.Participant
alias Mobilizon.Service.Akismet
alias Mobilizon.Storage.{Page, Repo}
alias Mobilizon.Users.User
import Mobilizon.Web.Gettext
@@ -137,6 +138,14 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
args = Map.put(args, :user_id, user.id)
with args <- Map.update(args, :preferred_username, "", &String.downcase/1),
{:akismet, :ham} <-
{:akismet,
Akismet.check_profile(
args.preferred_username,
args.summary,
user.email,
user.current_sign_in_ip
)},
{:picture, args} when is_map(args) <- {:picture, save_attached_pictures(args)},
{:ok, %Actor{} = new_person} <- Actors.new_person(args) do
{:ok, new_person}
@@ -290,21 +299,35 @@ defmodule Mobilizon.GraphQL.Resolvers.Person do
"""
@spec register_person(any(), map(), Absinthe.Resolution.t()) ::
{:ok, Actor.t()} | {:error, String.t()}
def register_person(_parent, args, _resolution) do
def register_person(_parent, args, %{context: context}) do
current_ip = Map.get(context, :ip)
user_agent = Map.get(context, :user_agent, "")
# When registering, email is assumed confirmed (unlike changing email)
case Users.get_user_by_email(args.email, unconfirmed: false) do
{:ok, %User{} = user} ->
if is_nil(Users.get_actor_for_user(user)) do
# No profile yet, we can create one
case prepare_args(args, user) do
args when is_map(args) ->
Actors.new_person(args, true)
with {:akismet, :ham} <-
{:akismet,
Akismet.check_profile(
args.preferred_username,
args.summary,
args.email,
current_ip,
user_agent
)},
args when is_map(args) <- prepare_args(args, user) do
Actors.new_person(args, true)
else
{:error, :file_too_large} ->
{:error, dgettext("errors", "The provided picture is too heavy")}
{:error, _err} ->
{:error, dgettext("errors", "Error while uploading pictures")}
{:akismet, _} ->
{:error, dgettext("errors", "Your profile was detected as spam.")}
end
else
{:error, dgettext("errors", "You already have a profile for this user")}

View File

@@ -98,12 +98,18 @@ defmodule Mobilizon.GraphQL.Resolvers.Report do
{:ok, Report.t()} | {:error, String.t()}
def update_report(
_parent,
%{report_id: report_id, status: status},
%{report_id: report_id, status: status} = args,
%{context: %{current_user: %User{role: role}, current_actor: %Actor{} = actor}}
)
when is_moderator(role) do
with %Report{} = report <- Mobilizon.Reports.get_report(report_id),
{:ok, %Report{} = report} <- API.Reports.update_report_status(actor, report, status) do
{:ok, %Report{} = report} <-
API.Reports.update_report_status(
actor,
report,
status,
Map.get(args, :antispam_feedback)
) do
{:ok, report}
else
_error ->

View File

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

View File

@@ -57,6 +57,11 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
value(:resolved, description: "The report has been marked as resolved")
end
enum :anti_spam_feedback do
value(:ham, description: "The report is ham")
value(:spam, description: "The report is spam")
end
object :report_queries do
@desc "Get all reports"
field :reports, :paginated_report_list do
@@ -103,6 +108,11 @@ defmodule Mobilizon.GraphQL.Schema.ReportType do
field :update_report_status, type: :report do
arg(:report_id, non_null(:id), description: "The report's ID")
arg(:status, non_null(:report_status), description: "The report's new status")
arg(:antispam_feedback, :anti_spam_feedback,
description: "The feedback to send to the anti-spam system"
)
resolve(&Report.update_report/3)
end