decidim-spam_detection 0.1.5

Sign up to get free protection for your applications and to get access to all the features.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # Decidim::SpamDetection
2
+ [![codecov](https://codecov.io/gh/OpenSourcePolitics/decidim-spam_detection/branch/master/graph/badge.svg?token=eJu34XLlVu)](https://codecov.io/gh/OpenSourcePolitics/decidim-spam_detection)
3
+ ![Tests](https://github.com/opensourcepolitics/decidim-spam_detection/actions/workflows/tests.yml/badge.svg)
4
+ ![Tests](https://github.com/opensourcepolitics/decidim-spam_detection/actions/workflows/lint.yml/badge.svg)
5
+
6
+ ## Usage
7
+
8
+ SpamDetection is a detection bot made by OpenSourcePolitics. It works with a [spam detection service](https://github.com/OpenSourcePolitics/spam_detection)
9
+ which marks the user with a spam probability score, between 0.7
10
+ and 0.99 it is probable, and above 0.99 it is very sure.
11
+
12
+ By default, the bot does not blocks the user, it only reports them.
13
+ All reports and blocks are made like regular Decidim ones.
14
+
15
+ ### Installation
16
+
17
+ Add this line to your application's Gemfile:
18
+
19
+ ```ruby
20
+ gem "decidim-spam_detection", git: "https://github.com/OpenSourcePolitics/decidim-spam_detection.git"
21
+ ```
22
+
23
+ And then execute:
24
+
25
+ ```bash
26
+ bundle exec rake decidim:spam_detection:mark_users
27
+ ```
28
+
29
+ if you are using sidekiq scheduler you can use the following configuration:
30
+ ```
31
+ :queues:
32
+ - user_report
33
+ - block_user
34
+ - scheduled
35
+
36
+ :schedule:
37
+ DetectSpamUsers:
38
+ cron: '0 0 8 * * *' # Run at 08:00
39
+ class: Decidim::SpamDetection::MarkUsersJob
40
+ queue: scheduled
41
+ ```
42
+
43
+ ### Further configuration
44
+ list of env var, default value and their usage:
45
+ ```
46
+ SPAM_DETECTION_API_AUTH_TOKEN
47
+ default_value: dummy
48
+ usage: Token auth for authentication used by external service, ask us for more details
49
+ SPAM_DETECTION_API_URL
50
+ default_value: "http://localhost:8080/api"
51
+ usage: URL of the external service
52
+ SPAM_DETECTION_NAME
53
+ default_value: "spam detection bot"
54
+ usage: Name used by the spam detection bot
55
+ SPAM_DETECTION_NICKNAME
56
+ default_value: "Spam_detection_bot"
57
+ usage: Nickname used by the spam detection bot
58
+ SPAM_DETECTION_EMAIL
59
+ default_value: "spam_detection_bot@opensourcepolitcs.eu"
60
+ usage: Email used by the spam detection bot
61
+ PERFORM_BLOCK_USER
62
+ default_value: false
63
+ usage: Determine if the bot can perform blocking, default mode is just report
64
+ ```
65
+
66
+ ## API usage
67
+ We can provide the detection service, please check us out at [contact@opensourcepolitics.eu](mailto:contact@opensourcepolitics.eu)
68
+
69
+ ## Contributing
70
+
71
+ See [Decidim](https://github.com/decidim/decidim).
72
+
73
+ ## License
74
+
75
+ This engine is distributed under the GNU AFFERO GENERAL PUBLIC LICENSE.
data/Rakefile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/dev/common_rake"
4
+
5
+ desc "Generates a dummy app for testing"
6
+ task test_app: "decidim:generate_external_test_app"
7
+
8
+ desc "Generates a development app."
9
+ task development_app: "decidim:generate_external_development_app"
10
+
11
+ task :push_tag_and_release do
12
+ system("git tag v#{Decidim::SpamDetection.version}")
13
+ system("git push --tags")
14
+ system("gh release create v#{Decidim::SpamDetection.version}")
15
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Admin
5
+ class UnblockUser < Rectify::Command
6
+ # Public: Initializes the command.
7
+ #
8
+ # blocked_user - the user that is unblocked
9
+ # current_user - the user performing the action
10
+ def initialize(blocked_user, current_user)
11
+ @blocked_user = blocked_user
12
+ @current_user = current_user
13
+ end
14
+
15
+ # Executes the command. Broadcasts these events:
16
+ #
17
+ # - :ok when everything is valid, together with the resource.
18
+ # - :invalid if the resource is not reported
19
+ #
20
+ # Returns nothing.
21
+ def call
22
+ return broadcast(:invalid) unless @blocked_user.blocked?
23
+
24
+ unblock!
25
+ add_spam_detection_metadata!
26
+ broadcast(:ok, @blocked_user)
27
+ end
28
+
29
+ private
30
+
31
+ def unblock!
32
+ Decidim.traceability.perform_action!(
33
+ "unblock",
34
+ @blocked_user,
35
+ @current_user,
36
+ extra: {
37
+ reportable_type: @blocked_user.class.name
38
+ }
39
+ ) do
40
+ @blocked_user.blocked = false
41
+ @blocked_user.blocked_at = nil
42
+ @blocked_user.block_id = nil
43
+ @blocked_user.name = @blocked_user.user_name
44
+ @blocked_user.save!
45
+ end
46
+ end
47
+
48
+ def add_spam_detection_metadata!
49
+ return if @blocked_user.extended_data.dig("spam_detection", "blocked_at").blank?
50
+
51
+ @blocked_user.update!(extended_data: @blocked_user.extended_data.dup.deep_merge("spam_detection" => { "unblocked_at": Time.current }))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module Admin
5
+ class UnreportUser < Rectify::Command
6
+ # Public: Initializes the command.
7
+ #
8
+ # reportable - A Decidim::User - The user reported
9
+ # current_user - the user performing the action
10
+ def initialize(reportable, current_user)
11
+ @reportable = reportable
12
+ @current_user = current_user
13
+ end
14
+
15
+ # Executes the command. Broadcasts these events:
16
+ #
17
+ # - :ok when everything is valid, together with the resource.
18
+ # - :invalid if the resource is not reported
19
+ #
20
+ # Returns nothing.
21
+ def call
22
+ return broadcast(:invalid) unless @reportable.reported?
23
+
24
+ unreport!
25
+ add_spam_detection_metadata!
26
+ broadcast(:ok, @reportable)
27
+ end
28
+
29
+ private
30
+
31
+ def unreport!
32
+ Decidim.traceability.perform_action!(
33
+ "unreport",
34
+ @reportable.user_moderation,
35
+ @current_user,
36
+ extra: {
37
+ reportable_type: @reportable.class.name,
38
+ username: @reportable.name,
39
+ user_id: @reportable.id
40
+ }
41
+ ) do
42
+ @reportable.user_moderation.destroy!
43
+ end
44
+ end
45
+
46
+ def add_spam_detection_metadata!
47
+ return if @reportable.extended_data.dig("spam_detection", "reported_at").blank?
48
+
49
+ @reportable.update!(extended_data: @reportable.extended_data.dup.deep_merge("spam_detection" => { "unreported_at": Time.current }))
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ class MarkUsersJob < ApplicationJob
6
+ queue_as :default
7
+
8
+ def perform
9
+ mark_users_service.call
10
+ end
11
+
12
+ private
13
+
14
+ def mark_users_service
15
+ @mark_users_service ||= Decidim::SpamDetection::MarkUsersService
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ class MarkUsersService
9
+ PUBLICY_SEARCHABLE_COLUMNS = [
10
+ :id,
11
+ :decidim_organization_id,
12
+ :sign_in_count,
13
+ :personal_url,
14
+ :about,
15
+ :avatar,
16
+ :extended_data,
17
+ :followers_count,
18
+ :following_count,
19
+ :invitations_count,
20
+ :failed_attempts,
21
+ :admin
22
+ ].freeze
23
+
24
+ def initialize
25
+ @users = Decidim::User.left_outer_joins(:user_moderation)
26
+ .where(decidim_user_moderations: { decidim_user_id: nil })
27
+ .where(admin: false, blocked: false, deleted_at: nil)
28
+ .where("(extended_data #> '{spam_detection, unreported_at}') is null")
29
+ .where("(extended_data #> '{spam_detection, unblocked_at}') is null")
30
+ @results = []
31
+ end
32
+
33
+ def self.call
34
+ new.ask_and_mark
35
+ end
36
+
37
+ def ask_and_mark
38
+ spam_probability_array = Decidim::SpamDetection::ApiProxy.request(cleaned_users)
39
+
40
+ mark_spam_users(merge_response_with_users(spam_probability_array))
41
+ end
42
+
43
+ def mark_spam_users(probability_array)
44
+ probability_array.each do |probability_hash|
45
+ @results << Decidim::SpamDetection::SpamUserCommandAdapter.call(probability_hash).result
46
+ end
47
+ end
48
+
49
+ def cleaned_users
50
+ @cleaned_users ||= @users.select(PUBLICY_SEARCHABLE_COLUMNS)
51
+ .map { |u| u.serializable_hash(force_except: true) }
52
+ end
53
+
54
+ def merge_response_with_users(response)
55
+ response.map { |resp| resp.merge("original_user" => @users.find(resp["id"])) }
56
+ end
57
+
58
+ def status
59
+ @results.tally
60
+ end
61
+ end
62
+ end
63
+ end
data/config/assets.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ base_path = File.expand_path("..", __dir__)
4
+
5
+ Decidim::Webpacker.register_path("#{base_path}/app/packs")
6
+ Decidim::Webpacker.register_entrypoints(
7
+ decidim_spam_detection: "#{base_path}/app/packs/entrypoints/decidim_spam_detection.js"
8
+ )
9
+ Decidim::Webpacker.register_stylesheet_import("stylesheets/decidim/spam_detection/spam_detection")
@@ -0,0 +1,10 @@
1
+ ---
2
+
3
+ base_locale: en
4
+ locales: [en]
5
+
6
+ ignore_unused:
7
+ - "decidim.components.spam_detection.name"
8
+
9
+ ignore_missing:
10
+ - decidim.participatory_processes.scopes.global
@@ -0,0 +1,6 @@
1
+ ---
2
+ en:
3
+ decidim:
4
+ components:
5
+ spam_detection:
6
+ name: SpamDetection
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ class AbstractSpamUserCommand
9
+ SPAM_USER = {
10
+ name: ENV.fetch("SPAM_DETECTION_NAME", "spam detection bot"),
11
+ nickname: ENV.fetch("SPAM_DETECTION_NICKNAME", "Spam_detection_bot"),
12
+ email: ENV.fetch("SPAM_DETECTION_EMAIL", "spam_detection_bot@opensourcepolitcs.eu")
13
+ }.freeze
14
+
15
+ include Decidim::FormFactory
16
+
17
+ def initialize(user, probability)
18
+ @user = user
19
+ @probability = probability
20
+ @moderator = moderation_user
21
+ end
22
+
23
+ def call
24
+ raise NotImplementedError
25
+ end
26
+
27
+ def moderation_user
28
+ moderation_admin_params = {
29
+ name: SPAM_USER[:name],
30
+ nickname: SPAM_USER[:nickname],
31
+ email: SPAM_USER[:email],
32
+ admin: true,
33
+ organization: @user.organization
34
+ }
35
+
36
+ moderation_admin = Decidim::User.find_by(moderation_admin_params)
37
+
38
+ return moderation_admin unless moderation_admin.nil?
39
+
40
+ create_moderation_admin(moderation_admin_params)
41
+ end
42
+
43
+ def create_moderation_admin(params)
44
+ password = ::Devise.friendly_token(::Devise.password_length.last)
45
+ additional_params = {
46
+ password: password,
47
+ password_confirmation: password,
48
+ tos_agreement: true,
49
+ email_on_notification: false,
50
+ email_on_moderations: false
51
+ }
52
+ moderation_admin = Decidim::User.new(params.merge(additional_params))
53
+ moderation_admin.skip_confirmation!
54
+ moderation_admin.save
55
+ moderation_admin
56
+ end
57
+
58
+ def add_spam_detection_metadata!(metadata)
59
+ @user.update!(extended_data: @user.extended_data
60
+ .dup
61
+ .deep_merge("spam_detection" => metadata))
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ # This module contains all the domain logic associated to Decidim's SpamDetection
6
+ # component admin panel.
7
+ module Admin
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ # This is the engine that runs on the public interface of `SpamDetection`.
6
+ class AdminEngine < ::Rails::Engine
7
+ isolate_namespace Decidim::SpamDetection::Admin
8
+
9
+ paths["db/migrate"] = nil
10
+ paths["lib/tasks"] = nil
11
+
12
+ routes do
13
+ # Add admin engine routes here
14
+ # resources :spam_detection do
15
+ # collection do
16
+ # resources :exports, only: [:create]
17
+ # end
18
+ # end
19
+ # root to: "spam_detection#index"
20
+ end
21
+
22
+ def load_seed
23
+ nil
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ class ApiProxy
9
+ URL = URI(ENV.fetch("SPAM_DETECTION_API_URL", "http://localhost:8080/api"))
10
+ AUTH_TOKEN = ENV.fetch("SPAM_DETECTION_API_AUTH_TOKEN", "dummy")
11
+
12
+ def initialize(data_array, batch_size)
13
+ @data_array = data_array
14
+ @batch_size = batch_size
15
+ @retries = [3, 5, 10]
16
+ end
17
+
18
+ def self.request(data_array, batch_size = 1000)
19
+ new(data_array, batch_size).send_request_in_batch
20
+ end
21
+
22
+ def send_request_in_batch
23
+ responses = []
24
+ @data_array.each_slice(@batch_size) do |subdata_array|
25
+ responses << JSON.parse(send_request_to_api(subdata_array))
26
+ end
27
+
28
+ responses.flatten
29
+ end
30
+
31
+ def send_request_to_api(data)
32
+ http = Net::HTTP.new(URL.host, URL.port)
33
+ request = Net::HTTP::Post.new(URL)
34
+ request["Content-Type"] = "application/json"
35
+ request["AUTH_TOKEN"] = AUTH_TOKEN
36
+ request.body = JSON.dump(data)
37
+ http.use_ssl = true if self.class.use_ssl?(URL)
38
+ response = http.request(request)
39
+ response.read_body
40
+ rescue Net::ReadTimeout
41
+ raise Net::ReadTimeout if @retries.empty?
42
+
43
+ sleep @retries.first
44
+ @retries.shift
45
+ retry
46
+ end
47
+
48
+ def self.use_ssl?(url)
49
+ url.scheme == "https"
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ class BlockSpamUserCommand < Decidim::SpamDetection::AbstractSpamUserCommand
9
+ prepend Decidim::SpamDetection::Command
10
+
11
+ def call
12
+ form = form(Decidim::Admin::BlockUserForm).from_params(
13
+ justification: "The user was blocked because of a high spam probability by Decidim spam detection bot"
14
+ )
15
+
16
+ moderator = @moderator
17
+ user = @user
18
+
19
+ form.define_singleton_method(:user) { user }
20
+ form.define_singleton_method(:current_user) { moderator }
21
+ form.define_singleton_method(:blocking_user) { moderator }
22
+
23
+ Decidim::Admin::BlockUser.call(form)
24
+
25
+ add_spam_detection_metadata!({
26
+ "blocked_at" => Time.current,
27
+ "spam_probability" => @probability
28
+ })
29
+
30
+ @user.create_user_moderation
31
+ Rails.logger.info("User with id #{@user["id"]} was blocked for spam")
32
+
33
+ :ok
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/spam_detection/command_errors"
4
+
5
+ module Decidim
6
+ module SpamDetection
7
+ module Command
8
+ attr_reader :result
9
+
10
+ module ClassMethods
11
+ def call(*args, **kwargs)
12
+ new(*args, **kwargs).call
13
+ end
14
+ end
15
+
16
+ def self.prepended(base)
17
+ base.extend ClassMethods
18
+ end
19
+
20
+ def call
21
+ raise NotImplementedError unless defined?(super)
22
+
23
+ @called = true
24
+ @result = super
25
+
26
+ self
27
+ end
28
+
29
+ def success?
30
+ called? && !failure?
31
+ end
32
+
33
+ def failure?
34
+ called? && errors.any?
35
+ end
36
+
37
+ def errors
38
+ return super if defined?(super)
39
+
40
+ @errors ||= Decidim::SpamDetection::CommandErrors.new
41
+ end
42
+
43
+ private
44
+
45
+ def called?
46
+ @called ||= false
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ class CommandErrors < Hash
6
+ def add(key, value, _opts = {})
7
+ self[key] ||= []
8
+ self[key] << value
9
+ self[key].uniq!
10
+ end
11
+
12
+ def add_multiple_errors(errors_hash)
13
+ errors_hash.each do |key, values|
14
+ values.each { |value| add key, value }
15
+ end
16
+ end
17
+
18
+ def each
19
+ each_key do |field|
20
+ self[field].each { |message| yield field, message }
21
+ end
22
+ end
23
+
24
+ def full_messages
25
+ map { |attribute, message| full_message(attribute, message) }
26
+ end
27
+
28
+ private
29
+
30
+ def full_message(attribute, message)
31
+ return message if attribute == :base
32
+
33
+ "#{attribute.to_s.tr(".", "_").capitalize} #{message}"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails"
4
+ require "decidim/core"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ # This is the engine that runs on the public interface of spam_detection.
9
+ class Engine < ::Rails::Engine
10
+ isolate_namespace Decidim::SpamDetection
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+
6
+ module Decidim
7
+ module SpamDetection
8
+ class ReportSpamUserCommand < Decidim::SpamDetection::AbstractSpamUserCommand
9
+ prepend Decidim::SpamDetection::Command
10
+
11
+ def call
12
+ form = form(Decidim::ReportForm).from_params(
13
+ reason: "spam",
14
+ details: "The user was marked as spam by Decidim spam detection bot"
15
+ )
16
+
17
+ current_organization = @user.organization
18
+ moderator = @moderator
19
+ user = @user
20
+
21
+ report = Decidim::CreateUserReport.new(form, user, moderator)
22
+ report.define_singleton_method(:current_organization) { current_organization }
23
+ report.define_singleton_method(:current_user) { moderator }
24
+ report.define_singleton_method(:reportable) { user }
25
+ report.call
26
+
27
+ add_spam_detection_metadata!({
28
+ "reported_at" => Time.current,
29
+ "spam_probability" => @probability
30
+ })
31
+
32
+ Rails.logger.info("User with id #{user.id} was reported for spam")
33
+
34
+ :ok
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Decidim
4
+ module SpamDetection
5
+ class SpamUserCommandAdapter
6
+ prepend Decidim::SpamDetection::Command
7
+ SPAM_LEVEL = { very_sure: 0.99, probable: 0.7 }.freeze
8
+
9
+ def self.perform_block_user?
10
+ ENV.fetch("PERFORM_BLOCK_USER", false)
11
+ end
12
+
13
+ def initialize(probability_hash)
14
+ @probability = probability_hash["spam_probability"]
15
+ @user = probability_hash["original_user"]
16
+ end
17
+
18
+ def call
19
+ if @probability > SPAM_LEVEL[:very_sure] && self.class.perform_block_user?
20
+ Decidim::SpamDetection::BlockSpamUserCommand.call(@user, @probability)
21
+
22
+ :blocked_user
23
+ elsif @probability > SPAM_LEVEL[:probable]
24
+ Decidim::SpamDetection::ReportSpamUserCommand.call(@user, @probability)
25
+
26
+ :reported_user
27
+ else
28
+ :nothing
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,3 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "decidim/core/test/factories"