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:
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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?(),
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
177
lib/mix/tasks/mobilizon/maintenance/detect_spam.ex
Normal file
177
lib/mix/tasks/mobilizon/maintenance/detect_spam.ex
Normal file
@@ -0,0 +1,177 @@
|
||||
defmodule Mix.Tasks.Mobilizon.Maintenance.DetectSpam do
|
||||
@moduledoc """
|
||||
Task to scan all profiles and events against spam detector and report them
|
||||
"""
|
||||
use Mix.Task
|
||||
alias Mobilizon.{Actors, Config, Events, Users}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Service.Akismet
|
||||
import Mix.Tasks.Mobilizon.Common
|
||||
alias Mobilizon.Federation.ActivityPub.Actions
|
||||
alias Mobilizon.Web.Endpoint
|
||||
alias Mobilizon.Web.Router.Helpers, as: Routes
|
||||
|
||||
@shortdoc "Scan all profiles and events against spam detector and report them"
|
||||
|
||||
@impl Mix.Task
|
||||
def run(options) do
|
||||
{options, [], []} =
|
||||
OptionParser.parse(
|
||||
options,
|
||||
strict: [
|
||||
dry_run: :boolean,
|
||||
verbose: :boolean,
|
||||
forward_reports: :boolean,
|
||||
local_only: :boolean
|
||||
],
|
||||
aliases: [
|
||||
d: :dry_run,
|
||||
v: :verbose,
|
||||
f: :forward_reports,
|
||||
l: :local_only
|
||||
]
|
||||
)
|
||||
|
||||
start_mobilizon()
|
||||
|
||||
unless Akismet.ready?() do
|
||||
shell_error("Akismet is missing an API key in the configuration")
|
||||
end
|
||||
|
||||
anonymous_actor_id = Config.anonymous_actor_id()
|
||||
|
||||
options
|
||||
|> Keyword.get(:local_only, false)
|
||||
|> profiles()
|
||||
|> Stream.flat_map(& &1)
|
||||
|> Stream.each(fn profile ->
|
||||
process_profile(profile, Keyword.put(options, :anonymous_actor_id, anonymous_actor_id))
|
||||
end)
|
||||
|> Stream.run()
|
||||
|
||||
options
|
||||
|> Keyword.get(:local_only, false)
|
||||
|> events()
|
||||
|> Stream.flat_map(& &1)
|
||||
|> Stream.each(fn event ->
|
||||
process_event(event, Keyword.put(options, :anonymous_actor_id, anonymous_actor_id))
|
||||
end)
|
||||
|> Stream.run()
|
||||
end
|
||||
|
||||
defp profiles(local_only) do
|
||||
shell_info("Starting scanning of profiles")
|
||||
|
||||
Actors.stream_persons("", "", "", local_only || nil, false)
|
||||
end
|
||||
|
||||
defp events(local_only) do
|
||||
shell_info("Starting scanning of events")
|
||||
|
||||
Events.stream_events(local_only || nil)
|
||||
end
|
||||
|
||||
defp process_profile(
|
||||
%Actor{preferred_username: preferred_username, summary: summary, user: user, id: id},
|
||||
options
|
||||
) do
|
||||
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)
|
||||
|
||||
case Akismet.check_profile(preferred_username, summary, email, ip) do
|
||||
res when res in [:spam, :discard] ->
|
||||
handle_spam_profile(preferred_username, id, options)
|
||||
|
||||
:ham ->
|
||||
if verbose?(options) do
|
||||
shell_info("Profile #{preferred_username} is fine")
|
||||
end
|
||||
|
||||
err ->
|
||||
shell_error(inspect(err))
|
||||
end
|
||||
end
|
||||
|
||||
defp process_event(
|
||||
%Event{
|
||||
description: event_description,
|
||||
organizer_actor: organizer_actor,
|
||||
id: event_id,
|
||||
title: title,
|
||||
uuid: uuid
|
||||
},
|
||||
options
|
||||
) do
|
||||
{email, ip} =
|
||||
if organizer_actor.user_id do
|
||||
user = Users.get_user(organizer_actor.user_id)
|
||||
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)
|
||||
{email, ip}
|
||||
else
|
||||
{nil, nil}
|
||||
end
|
||||
|
||||
case Akismet.check_event(event_description, organizer_actor.preferred_username, email, ip) do
|
||||
res when res in [:spam, :discard] ->
|
||||
handle_spam_event(event_id, title, uuid, organizer_actor.id, options)
|
||||
|
||||
:ham ->
|
||||
if verbose?(options) do
|
||||
shell_info("Event #{title} is fine")
|
||||
end
|
||||
|
||||
err ->
|
||||
shell_error(inspect(err))
|
||||
end
|
||||
end
|
||||
|
||||
defp handle_spam_profile(preferred_username, organizer_actor_id, options) do
|
||||
shell_info("Detected profile #{preferred_username} as spam")
|
||||
|
||||
unless dry_run?(options) do
|
||||
report_spam_profile(preferred_username, organizer_actor_id, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp report_spam_profile(profile_preferred_username, organizer_actor_id, options) do
|
||||
shell_info("Reporting profile #{profile_preferred_username} as spam")
|
||||
|
||||
Actions.Flag.flag(
|
||||
%{
|
||||
reported_id: organizer_actor_id,
|
||||
reporter_id: Keyword.fetch!(options, :anonymous_actor_id),
|
||||
content: "This is an automatic report issued by Akismet"
|
||||
},
|
||||
Keyword.get(options, :forward_reports, false)
|
||||
)
|
||||
end
|
||||
|
||||
defp handle_spam_event(event_id, event_title, event_uuid, organizer_actor_id, options) do
|
||||
shell_info(
|
||||
"Detected event #{event_title} as spam: #{Routes.page_url(Endpoint, :event, event_uuid)}"
|
||||
)
|
||||
|
||||
unless dry_run?(options) do
|
||||
report_spam_event(event_id, event_title, organizer_actor_id, options)
|
||||
end
|
||||
end
|
||||
|
||||
defp report_spam_event(event_id, event_title, organizer_actor_id, options) do
|
||||
shell_info("Reporting event #{event_title} as spam")
|
||||
|
||||
Actions.Flag.flag(
|
||||
%{
|
||||
reported_id: organizer_actor_id,
|
||||
reporter_id: Keyword.fetch!(options, :anonymous_actor_id),
|
||||
event_id: event_id,
|
||||
content: "This is an automatic report issued by Akismet"
|
||||
},
|
||||
Keyword.get(options, :forward_reports, false)
|
||||
)
|
||||
end
|
||||
|
||||
defp verbose?(options), do: Keyword.get(options, :verbose, false)
|
||||
defp dry_run?(options), do: Keyword.get(options, :dry_run, false)
|
||||
end
|
||||
@@ -320,8 +320,8 @@ defmodule Mobilizon.Actors do
|
||||
String.t(),
|
||||
String.t(),
|
||||
String.t(),
|
||||
boolean,
|
||||
boolean,
|
||||
boolean | nil,
|
||||
boolean | nil,
|
||||
integer | nil,
|
||||
integer | nil
|
||||
) :: Page.t(Actor.t())
|
||||
@@ -380,8 +380,8 @@ defmodule Mobilizon.Actors do
|
||||
String.t(),
|
||||
String.t(),
|
||||
String.t(),
|
||||
boolean(),
|
||||
boolean()
|
||||
boolean() | nil,
|
||||
boolean() | nil
|
||||
) ::
|
||||
Ecto.Query.t()
|
||||
defp filter_actors(
|
||||
@@ -417,10 +417,12 @@ defmodule Mobilizon.Actors do
|
||||
|
||||
defp filter_remote(query, true), do: filter_local(query)
|
||||
defp filter_remote(query, false), do: filter_external(query)
|
||||
defp filter_remote(query, nil), do: query
|
||||
|
||||
@spec filter_suspended(Ecto.Queryable.t(), boolean()) :: Ecto.Query.t()
|
||||
@spec filter_suspended(Ecto.Queryable.t(), boolean() | nil) :: Ecto.Query.t()
|
||||
defp filter_suspended(query, true), do: where(query, [a], a.suspended)
|
||||
defp filter_suspended(query, false), do: where(query, [a], not a.suspended)
|
||||
defp filter_suspended(query, nil), do: query
|
||||
|
||||
@spec filter_out_anonymous_actor_id(Ecto.Queryable.t(), integer() | String.t()) ::
|
||||
Ecto.Query.t()
|
||||
@@ -1766,4 +1768,26 @@ defmodule Mobilizon.Actors do
|
||||
)
|
||||
|> Repo.all()
|
||||
end
|
||||
|
||||
@spec stream_persons(
|
||||
String.t(),
|
||||
String.t(),
|
||||
String.t(),
|
||||
boolean | nil,
|
||||
boolean | nil,
|
||||
integer()
|
||||
) :: Enum.t()
|
||||
def stream_persons(
|
||||
preferred_username \\ "",
|
||||
name \\ "",
|
||||
domain \\ "",
|
||||
local \\ true,
|
||||
suspended \\ false,
|
||||
chunk_size \\ 500
|
||||
) do
|
||||
person_query()
|
||||
|> filter_actors(preferred_username, name, domain, local, suspended)
|
||||
|> preload([:user])
|
||||
|> Page.chunk(chunk_size)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1819,7 +1819,14 @@ defmodule Mobilizon.Events do
|
||||
|
||||
@spec filter_local(Ecto.Queryable.t()) :: Ecto.Query.t()
|
||||
defp filter_local(query) do
|
||||
where(query, [q], q.local == true)
|
||||
filter_local(query, true)
|
||||
end
|
||||
|
||||
@spec filter_local(Ecto.Queryable.t(), boolean() | nil) :: Ecto.Query.t()
|
||||
defp filter_local(query, nil), do: query
|
||||
|
||||
defp filter_local(query, value) when is_boolean(value) do
|
||||
where(query, [q], q.local == ^value)
|
||||
end
|
||||
|
||||
@spec filter_local_or_from_followed_instances_events(Ecto.Queryable.t()) ::
|
||||
@@ -1938,4 +1945,13 @@ defmodule Mobilizon.Events do
|
||||
|
||||
@spec preload_for_event(Ecto.Queryable.t()) :: Ecto.Query.t()
|
||||
defp preload_for_event(query), do: preload(query, ^@event_preloads)
|
||||
|
||||
@spec stream_events(boolean() | nil, integer()) :: Enum.t()
|
||||
def stream_events(local \\ true, chunk_size \\ 500) do
|
||||
Event
|
||||
|> filter_draft()
|
||||
|> filter_local(local)
|
||||
|> preload_for_event()
|
||||
|> Page.chunk(chunk_size)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,4 +48,34 @@ defmodule Mobilizon.Storage.Page do
|
||||
def paginate(query, page, size) do
|
||||
from(query, limit: ^size, offset: ^((page - 1) * size))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Stream chunks of results from the given queryable.
|
||||
|
||||
Unlike Repo.stream, this function does not keep a long running transaction open.
|
||||
Hence, consistency is not guarenteed in the presence of rows being deleted or sort criteria changing.
|
||||
|
||||
## Example
|
||||
|
||||
Ecto.Query.from(u in Users, order_by: [asc: :created_at])
|
||||
|> Repo.chunk(100)
|
||||
|> Stream.map(&process_batch_of_users)
|
||||
|> Stream.run()
|
||||
|
||||
## Source
|
||||
https://elixirforum.com/t/what-is-the-best-approach-for-fetching-large-amount-of-records-from-postgresql-with-ecto/3766/8
|
||||
"""
|
||||
@spec chunk(Ecto.Queryable.t(), integer) :: Stream.t()
|
||||
def chunk(queryable, chunk_size) do
|
||||
chunk_stream =
|
||||
Stream.unfold(1, fn page_number ->
|
||||
page = queryable |> paginate(page_number, chunk_size) |> Repo.all()
|
||||
{page, page_number + 1}
|
||||
end)
|
||||
|
||||
Stream.take_while(chunk_stream, fn
|
||||
[] -> false
|
||||
_ -> true
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
251
lib/service/akismet.ex
Normal file
251
lib/service/akismet.ex
Normal file
@@ -0,0 +1,251 @@
|
||||
defmodule Mobilizon.Service.Akismet do
|
||||
@moduledoc """
|
||||
Validate user data
|
||||
"""
|
||||
|
||||
alias Exkismet.Comment, as: AkismetComment
|
||||
alias Mobilizon.{Actors, Discussions, Events, Users}
|
||||
alias Mobilizon.Actors.Actor
|
||||
alias Mobilizon.Discussions.Comment
|
||||
alias Mobilizon.Events.Event
|
||||
alias Mobilizon.Reports.Report
|
||||
alias Mobilizon.Users.User
|
||||
alias Mobilizon.Web.Endpoint
|
||||
require Logger
|
||||
|
||||
@env Application.compile_env(:mobilizon, :env)
|
||||
|
||||
@spec check_user(String.t(), String.t(), String.t()) ::
|
||||
:ham | :spam | :discard | {:error, HTTPoison.Response.t()}
|
||||
def check_user(email, ip, user_agent) do
|
||||
check_content(%AkismetComment{
|
||||
blog: homepage(),
|
||||
user_ip: ip,
|
||||
comment_author_email: email,
|
||||
user_agent: user_agent,
|
||||
comment_type: "signup"
|
||||
})
|
||||
end
|
||||
|
||||
@spec check_profile(String.t(), String.t(), String.t() | nil, String.t(), String.t()) ::
|
||||
:ham | :spam | :discard | {:error, HTTPoison.Response.t()}
|
||||
def check_profile(username, summary, email \\ nil, ip \\ "127.0.0.1", user_agent \\ nil) do
|
||||
check_content(%AkismetComment{
|
||||
blog: homepage(),
|
||||
user_ip: ip,
|
||||
comment_author: username,
|
||||
comment_author_email: email,
|
||||
comment_content: summary,
|
||||
user_agent: user_agent,
|
||||
comment_type: "signup"
|
||||
})
|
||||
end
|
||||
|
||||
@spec check_event(String.t(), String.t(), String.t() | nil, String.t(), String.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
|
||||
check_content(%AkismetComment{
|
||||
blog: homepage(),
|
||||
user_ip: ip,
|
||||
comment_author: username,
|
||||
comment_author_email: email,
|
||||
comment_content: event_body,
|
||||
user_agent: user_agent,
|
||||
comment_type: "blog-post"
|
||||
})
|
||||
end
|
||||
|
||||
@spec check_comment(String.t(), String.t(), boolean(), String.t() | nil, String.t(), String.t()) ::
|
||||
:ham | :spam | :discard | {:error, HTTPoison.Response.t()}
|
||||
def check_comment(
|
||||
comment_body,
|
||||
username,
|
||||
is_reply?,
|
||||
email \\ nil,
|
||||
ip \\ "127.0.0.1",
|
||||
user_agent \\ nil
|
||||
) do
|
||||
check_content(%AkismetComment{
|
||||
blog: homepage(),
|
||||
user_ip: ip,
|
||||
comment_author: username,
|
||||
comment_author_email: email,
|
||||
comment_content: comment_body,
|
||||
user_agent: user_agent,
|
||||
comment_type: if(is_reply?, do: "reply", else: "comment")
|
||||
})
|
||||
end
|
||||
|
||||
@spec report_ham(Report.t()) :: :ok | {:error, atom()} | {:error, HTTPoison.Response.t()}
|
||||
def report_ham(%Report{} = report) do
|
||||
report
|
||||
|> report_to_akismet_comment()
|
||||
|> submit_ham()
|
||||
end
|
||||
|
||||
@spec report_spam(Report.t()) :: :ok | {:error, atom()} | {:error, HTTPoison.Response.t()}
|
||||
def report_spam(%Report{} = report) do
|
||||
report
|
||||
|> report_to_akismet_comment()
|
||||
|> submit_spam()
|
||||
end
|
||||
|
||||
@spec homepage() :: String.t()
|
||||
defp homepage do
|
||||
Endpoint.url()
|
||||
end
|
||||
|
||||
defp check_content(%AkismetComment{} = comment) do
|
||||
if @env != :test and ready?() do
|
||||
comment
|
||||
|> Exkismet.comment_check(key: api_key())
|
||||
else
|
||||
:ham
|
||||
end
|
||||
end
|
||||
|
||||
defp api_key do
|
||||
Application.get_env(:mobilizon, __MODULE__) |> get_in([:key])
|
||||
end
|
||||
|
||||
def ready?, do: !is_nil(api_key())
|
||||
|
||||
@spec report_to_akismet_comment(Report.t()) :: AkismetComment.t() | {:error, atom()}
|
||||
defp report_to_akismet_comment(%Report{comments: [comment | _]}) do
|
||||
with %Comment{text: body, actor: %Actor{} = actor} <-
|
||||
Discussions.get_comment_with_preload(comment.id),
|
||||
{email, preferred_username, ip} <- actor_details(actor) do
|
||||
%AkismetComment{
|
||||
blog: homepage(),
|
||||
comment_content: body,
|
||||
comment_author_email: email,
|
||||
comment_author: preferred_username,
|
||||
user_ip: ip
|
||||
}
|
||||
else
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
|
||||
err ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
defp report_to_akismet_comment(%Report{event: %Event{id: event_id}}) do
|
||||
with %Event{description: body, organizer_actor: %Actor{} = actor} <-
|
||||
Events.get_event_with_preload!(event_id),
|
||||
{email, preferred_username, ip} <- actor_details(actor) do
|
||||
%AkismetComment{
|
||||
blog: homepage(),
|
||||
comment_content: body,
|
||||
comment_author_email: email,
|
||||
comment_author: preferred_username,
|
||||
user_ip: ip
|
||||
}
|
||||
else
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
|
||||
err ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
defp report_to_akismet_comment(%Report{reported_id: reported_id}) do
|
||||
case reported_id |> Actors.get_actor_with_preload!() |> actor_details() do
|
||||
{email, preferred_username, ip} ->
|
||||
%AkismetComment{
|
||||
blog: homepage(),
|
||||
comment_author_email: email,
|
||||
comment_author: preferred_username,
|
||||
user_ip: ip
|
||||
}
|
||||
|
||||
{:error, err} ->
|
||||
{:error, err}
|
||||
|
||||
err ->
|
||||
{:error, err}
|
||||
end
|
||||
end
|
||||
|
||||
@spec actor_details(Actor.t()) :: {String.t(), String.t(), any()} | {:error, :invalid_actor}
|
||||
defp actor_details(%Actor{
|
||||
type: :Person,
|
||||
preferred_username: preferred_username,
|
||||
user: %User{
|
||||
current_sign_in_ip: current_sign_in_ip,
|
||||
last_sign_in_ip: last_sign_in_ip,
|
||||
email: email
|
||||
}
|
||||
}) do
|
||||
{email, preferred_username, current_sign_in_ip || last_sign_in_ip}
|
||||
end
|
||||
|
||||
defp actor_details(%Actor{
|
||||
type: :Person,
|
||||
preferred_username: preferred_username,
|
||||
user_id: user_id
|
||||
})
|
||||
when not is_nil(user_id) do
|
||||
case user_id |> Users.get_user() |> user_details() do
|
||||
{email, ip} ->
|
||||
{preferred_username, email, ip}
|
||||
|
||||
err ->
|
||||
{:error, :invalid_actor}
|
||||
end
|
||||
end
|
||||
|
||||
defp actor_details(%Actor{
|
||||
type: :Person,
|
||||
preferred_username: preferred_username,
|
||||
user_id: nil
|
||||
}) do
|
||||
{nil, preferred_username, "127.0.0.1"}
|
||||
end
|
||||
|
||||
defp actor_details(err) do
|
||||
{:error, :invalid_actor}
|
||||
end
|
||||
|
||||
@spec user_details(User.t()) :: {String.t(), any()} | {:error, :user_not_found}
|
||||
defp user_details(%User{
|
||||
current_sign_in_ip: current_sign_in_ip,
|
||||
last_sign_in_ip: last_sign_in_ip,
|
||||
email: email
|
||||
}) do
|
||||
{email, current_sign_in_ip || last_sign_in_ip}
|
||||
end
|
||||
|
||||
defp user_details(_), do: {:error, :user_not_found}
|
||||
|
||||
@spec submit_spam(AkismetComment.t() | :error) ::
|
||||
:ok | {:error, atom()} | {:error, HTTPoison.Response.t()}
|
||||
defp submit_spam(%AkismetComment{} = comment) do
|
||||
comment
|
||||
|> tap(fn comment ->
|
||||
Logger.info("Submitting content to Akismet as spam: #{inspect(comment)}")
|
||||
end)
|
||||
|> Exkismet.submit_spam(key: api_key())
|
||||
|> log_response()
|
||||
end
|
||||
|
||||
defp submit_spam({:error, err}), do: {:error, err}
|
||||
|
||||
@spec submit_ham(AkismetComment.t() | :error) ::
|
||||
:ok | {:error, atom()} | {:error, HTTPoison.Response.t()}
|
||||
defp submit_ham(%AkismetComment{} = comment) do
|
||||
comment
|
||||
|> tap(fn comment ->
|
||||
Logger.info("Submitting content to Akismet as ham: #{inspect(comment)}")
|
||||
end)
|
||||
|> Exkismet.submit_ham(key: api_key())
|
||||
|> log_response()
|
||||
end
|
||||
|
||||
defp submit_ham({:error, err}), do: {:error, err}
|
||||
|
||||
defp log_response(res),
|
||||
do: tap(res, fn res -> Logger.debug("Return from Akismet is: #{inspect(res)}") end)
|
||||
end
|
||||
Reference in New Issue
Block a user