Commit 43e756d4 authored by Patricio Cano's avatar Patricio Cano
Browse files

Refactored AkismetHelper into AkismetService and cleaned up `Spammable`

- Refactored SpamCheckService into SpamService
parent 2f42d6505bd3
class Admin::SpamLogsController < Admin::ApplicationController
include Gitlab::AkismetHelper
def index
@spam_logs = SpamLog.order(id: :desc).page(params[:page])
......@@ -20,8 +19,7 @@ def destroy
def mark_as_ham
spam_log = SpamLog.find(params[:id])
if ham!(spam_log.source_ip, spam_log.user_agent, spam_log.text, spam_log.user)
spam_log.update_attribute(:submitted_as_ham, true)
if SpamService.new(spam_log).mark_as_ham!
redirect_to admin_spam_logs_path, notice: 'Spam log successfully submitted as ham.'
else
redirect_to admin_spam_logs_path, notice: 'Error with Akismet. Please check the logs for more info.'
......
......@@ -6,13 +6,7 @@ module SpammableActions
end
def mark_as_spam
if spammable.submit_spam
spammable.user_agent_detail.update_attribute(:submitted, true)
if spammable.is_a?(Issuable)
SystemNoteService.submit_spam(spammable, spammable.project, current_user)
end
if SpamService.new(spammable).mark_as_spam!(current_user)
redirect_to spammable, notice: 'Issue was submitted to Akismet successfully.'
else
flash[:error] = 'Error with Akismet. Please check the logs for more info.'
......
module Spammable
extend ActiveSupport::Concern
include Gitlab::AkismetHelper
module ClassMethods
def attr_spammable(*attrs)
attrs.each do |attr|
spammable_attrs << attr.to_s
end
def attr_spammable(attr, options = {})
spammable_attrs << [attr.to_s, options]
end
end
included do
has_one :user_agent_detail, as: :subject, dependent: :destroy
attr_accessor :spam
after_validation :check_for_spam, on: :create
after_validation :spam_detected?, on: :create
cattr_accessor :spammable_attrs, instance_accessor: false do
[]
end
delegate :submitted?, to: :user_agent_detail, allow_nil: true
end
def can_be_submitted?
if user_agent_detail
user_agent_detail.submittable? && akismet_enabled?
user_agent_detail.submittable?
else
false
end
end
def submit_spam
return unless akismet_enabled? && can_be_submitted?
spam!(user_agent_detail, spammable_text, owner)
end
def spam_detected?(env)
@spam = is_spam?(env, owner, spammable_text)
end
def spam?
@spam
end
def submitted?
if user_agent_detail
user_agent_detail.submitted
else
false
end
end
def check_for_spam
def spam_detected?
self.errors.add(:base, "Your #{self.class.name.underscore} has been recognized as spam and has been discarded.") if spam?
end
......@@ -61,34 +42,41 @@ def owner_id
end
end
def to_ability_name
self.class.to_s.underscore
end
# Override this method if an additional check is needed before calling Akismet
def check_for_spam?
akismet_enabled?
def owner
User.find(owner_id)
end
def spam_title
raise NotImplementedError
attr = self.class.spammable_attrs.select do |_, options|
options.fetch(:spam_title, false)
end
attr = attr[0].first
public_send(attr) if respond_to?(attr.to_sym)
end
def spam_description
raise NotImplementedError
end
attr = self.class.spammable_attrs.select do |_, options|
options.fetch(:spam_description, false)
end
private
attr = attr[0].first
public_send(attr) if respond_to?(attr.to_sym)
end
def spammable_text
result = []
self.class.spammable_attrs.each do |entry|
result << self.send(entry)
self.class.spammable_attrs.map do |attr|
result << public_send(attr.first)
end
result.reject(&:blank?).join("\n")
end
def owner
User.find(owner_id)
# Override in Spammable if further checks are necessary
def check_for_spam?
current_application_settings.akismet_enabled
end
end
......@@ -37,7 +37,8 @@ class Issue < ActiveRecord::Base
scope :order_due_date_asc, -> { reorder('issues.due_date IS NULL, issues.due_date ASC') }
scope :order_due_date_desc, -> { reorder('issues.due_date IS NULL, issues.due_date DESC') }
attr_spammable :title, :description
attr_spammable :title, spam_title: true
attr_spammable :description, spam_description: true
state_machine :state, initial: :opened do
event :close do
......@@ -266,16 +267,8 @@ def overdue?
due_date.try(:past?) || false
end
# To allow polymorphism with Spammable
# Only issues on public projects should be checked for spam
def check_for_spam?
super && project.public?
end
def spam_title
title
end
def spam_description
description
end
end
class AkismetService
attr_accessor :spammable
def initialize(spammable)
@spammable = spammable
end
def client_ip(env)
env['action_dispatch.remote_ip'].to_s
end
def user_agent(env)
env['HTTP_USER_AGENT']
end
def is_spam?(environment)
ip_address = client_ip(environment)
user_agent = user_agent(environment)
params = {
type: 'comment',
text: spammable.spammable_text,
created_at: DateTime.now,
author: spammable.owner.name,
author_email: spammable.owner.email,
referrer: environment['HTTP_REFERER'],
}
begin
is_spam, is_blatant = akismet_client.check(ip_address, user_agent, params)
is_spam || is_blatant
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check")
false
end
end
def ham!
params = {
type: 'comment',
text: spammable.text,
author: spammable.user.name,
author_email: spammable.user.email
}
begin
akismet_client.submit_ham(spammable.source_ip, spammable.user_agent, params)
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
false
end
end
def spam!
params = {
type: 'comment',
text: spammable.spammable_text,
author: spammable.owner.name,
author_email: spammable.owner.email
}
begin
akismet_client.submit_spam(spammable.user_agent_detail.ip_address, spammable.user_agent_detail.user_agent, params)
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
false
end
end
private
def akismet_client
@akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
Gitlab.config.gitlab.url)
end
end
......@@ -8,7 +8,7 @@ def execute
@issue = project.issues.new(params)
@issue.author = params[:author] || current_user
spam_check_service.execute
@issue.spam = spam_service.check(@api, @request)
if @issue.save
@issue.update_attributes(label_ids: label_params)
......@@ -25,8 +25,8 @@ def execute
private
def spam_check_service
SpamCheckService.new(@request, @api, @issue)
def spam_service
SpamService.new(@issue)
end
def user_agent_detail_service
......
class SpamCheckService
attr_accessor :request, :api, :spammable
def initialize(request, api, spammable)
@request, @api, @spammable = request, api, spammable
end
def execute
if request && spammable.check_for_spam?
if spammable.spam_detected?(request.env)
create_spam_log
end
end
end
private
def spam_log_attrs
{
user_id: spammable.owner_id,
title: spammable.spam_title,
description: spammable.spam_description,
source_ip: spammable.client_ip(request.env),
user_agent: spammable.user_agent(request.env),
noteable_type: spammable.class.to_s,
via_api: api
}
end
def create_spam_log
SpamLog.create(spam_log_attrs)
end
end
class SpamService
attr_accessor :spammable
def initialize(spammable)
@spammable = spammable
end
def check(api, request)
return false unless request && spammable.check_for_spam?
return false unless akismet.is_spam?(request.env)
create_spam_log(api, request)
true
end
def mark_as_spam!(current_user)
return false unless akismet_enabled? && spammable.can_be_submitted?
if akismet.spam!
spammable.user_agent_detail.update_attribute(:submitted, true)
if spammable.is_a?(Issuable)
SystemNoteService.submit_spam(spammable, spammable.project, current_user)
end
true
else
false
end
end
def mark_as_ham!
return false unless spammable.is_a?(SpamLog)
if akismet.ham!
spammable.update_attribute(:submitted_as_ham, true)
true
else
false
end
end
private
def akismet
@akismet ||= AkismetService.new(spammable)
end
def akismet_enabled?
current_application_settings.akismet_enabled
end
def create_spam_log(api, request)
SpamLog.create(
{
user_id: spammable.owner_id,
title: spammable.spam_title,
description: spammable.spam_description,
source_ip: akismet.client_ip(request.env),
user_agent: akismet.user_agent(request.env),
noteable_type: spammable.class.to_s,
via_api: api
}
)
end
end
......@@ -395,7 +395,7 @@ def noteable_moved(noteable, project, noteable_ref, author, direction:)
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the status of a Issuable is submitted as spam
# Called when Issuable is submitted as spam
#
# noteable - Noteable object
# project - Project owning noteable
......@@ -407,7 +407,7 @@ def noteable_moved(noteable, project, noteable_ref, author, direction:)
#
# Returns the created Note object
def submit_spam(noteable, project, author)
body = "Submitted #{noteable.class.to_s.downcase} as spam"
body = "Submitted this #{noteable.class.to_s.downcase} as spam"
create_note(noteable: noteable, project: project, author: author, note: body)
end
......
class CreateUserAgentDetails < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
def change
create_table :user_agent_details do |t|
t.string :user_agent, null: false
......
......@@ -10,7 +10,7 @@ class RemoveProjectIdFromSpamLogs < ActiveRecord::Migration
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
DOWNTIME_REASON = 'Removing a table that contains data that is not used anywhere.'
DOWNTIME_REASON = 'Removing a column that contains data that is not used anywhere.'
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
......
......@@ -3,8 +3,6 @@ module API
class Issues < Grape::API
before { authenticate! }
helpers ::Gitlab::AkismetHelper
helpers do
def filter_issues_state(issues, state)
case state
......
module Gitlab
module AkismetHelper
def akismet_enabled?
current_application_settings.akismet_enabled
end
def akismet_client
@akismet_client ||= ::Akismet::Client.new(current_application_settings.akismet_api_key,
Gitlab.config.gitlab.url)
end
def client_ip(env)
env['action_dispatch.remote_ip'].to_s
end
def user_agent(env)
env['HTTP_USER_AGENT']
end
def is_spam?(environment, user, text)
client = akismet_client
ip_address = client_ip(environment)
user_agent = user_agent(environment)
params = {
type: 'comment',
text: text,
created_at: DateTime.now,
author: user.name,
author_email: user.email,
referrer: environment['HTTP_REFERER'],
}
begin
is_spam, is_blatant = client.check(ip_address, user_agent, params)
is_spam || is_blatant
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping check")
false
end
end
def ham!(ip_address, user_agent, text, user)
client = akismet_client
params = {
type: 'comment',
text: text,
author: user.name,
author_email: user.email
}
begin
client.submit_ham(ip_address, user_agent, params)
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
false
end
end
def spam!(details, text, user)
client = akismet_client
params = {
type: 'comment',
text: text,
author: user.name,
author_email: user.email
}
begin
client.submit_spam(details.ip_address, details.user_agent, params)
true
rescue => e
Rails.logger.error("Unable to connect to Akismet: #{e}, skipping!")
false
end
end
end
end
......@@ -37,7 +37,7 @@
describe '#mark_as_ham' do
before do
allow_any_instance_of(Gitlab::AkismetHelper).to receive(:ham!).and_return(true)
allow_any_instance_of(AkismetService).to receive(:ham!).and_return(true)
end
it 'submits the log as ham' do
post :mark_as_ham, id: first_spam.id
......
......@@ -275,7 +275,7 @@ def go(id:)
context 'Akismet is enabled' do
before do
allow_any_instance_of(Spammable).to receive(:check_for_spam?).and_return(true)
allow_any_instance_of(Gitlab::AkismetHelper).to receive(:is_spam?).and_return(true)
allow_any_instance_of(AkismetService).to receive(:is_spam?).and_return(true)
end
def post_spam_issue
......@@ -325,7 +325,9 @@ def post_new_issue
describe 'POST #mark_as_spam' do
context 'properly submits to Akismet' do
before do
allow_any_instance_of(Spammable).to receive_messages(can_be_submitted?: true, submit_spam: true)
allow_any_instance_of(AkismetService).to receive_messages(spam!: true)
allow_any_instance_of(ApplicationSetting).to receive_messages(akismet_enabled: true)
allow_any_instance_of(SpamService).to receive_messages(can_be_submitted?: true)
end
def post_spam
......
require 'spec_helper'
describe Gitlab::AkismetHelper, type: :helper do
let(:project) { create(:project, :public) }
let(:user) { create(:user) }
before do
allow(Gitlab.config.gitlab).to receive(:url).and_return(Settings.send(:build_gitlab_url))
allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true)
allow_any_instance_of(ApplicationSetting).to receive(:akismet_api_key).and_return('12345')
end
describe '#is_spam?' do
it 'returns true for spam' do
environment = {
'action_dispatch.remote_ip' => '127.0.0.1',
'HTTP_USER_AGENT' => 'Test User Agent'
}
allow_any_instance_of(::Akismet::Client).to receive(:check).and_return([true, true])
expect(helper.is_spam?(environment, user, 'Is this spam?')).to eq(true)
end
end
end
......@@ -14,25 +14,24 @@
end
describe 'InstanceMethods' do
before do
allow_any_instance_of(Gitlab::AkismetHelper).to receive(:akismet_enabled?).and_return(true)
end
it 'should return the correct creator' do
expect(issue.send(:owner).id).to eq(issue.author_id)
expect(issue.owner_id).to eq(issue.author_id)
end
it 'should be invalid if spam' do
issue.spam = true
expect(issue.valid?).to be_truthy
issue = build(:issue, spam: true)
expect(issue.valid?).to be_falsey
end
it 'should be submittable' do
it 'should not be submitted' do
create(:user_agent_detail, subject: issue)
expect(issue.can_be_submitted?).to be_truthy
expect(issue.submitted?).to be_falsey
end
describe '#check_for_spam?' do
before do
allow_any_instance_of(ApplicationSetting).to receive(:akismet_enabled).and_return(true)
end
it 'returns true for public project' do
issue.project.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PUBLIC)
expect(issue.check_for_spam?).to eq(true)
......@@ -43,14 +42,4 @@
end
end
end
describe 'AkismetMethods' do
before do
allow_any_instance_of(Gitlab::AkismetHelper).to receive_messages(is_spam?: true, spam!: true, akismet_enabled?: true)
allow_any_instance_of(Spammable).to receive(:can_be_submitted?).and_return(true)
end
it { expect(issue.spam_detected?(:mock_env)).to be_truthy }
it { expect(issue.submit_spam).to be_truthy }