From 391423f25609daccec9cd875d1482307878b81a8 Mon Sep 17 00:00:00 2001 From: Sven Fuchs Date: Sun, 13 Jul 2008 20:30:54 +0200 Subject: [PATCH] fresh approach to spam protection with a filter_chain to make things more flexible --- TODO | 59 +++++++++- db/schema.rb | 10 +- spec/controllers/comments_controller_spec.rb | 2 +- spec/models/comment_spec.rb | 23 ++-- spec/models/spam/akismet.rb | 100 ---------------- spec/models/spam/defensio.rb | 107 ------------------ spec/models/spam/none.rb | 20 ---- spec/models/spam/spam.rb | 70 ------------ spec/models/spam_engine/akismet.rb | 82 ++++++++++++++ spec/models/spam_engine/default.rb | 38 +++++++ spec/models/spam_engine/defensio.rb | 85 ++++++++++++++ spec/models/spam_engine/filter_chain.rb | 39 +++++++ spec/stubs/comment.rb | 2 +- vendor/engines/adva_cms/app/models/section.rb | 10 +- vendor/engines/adva_cms/init.rb | 7 +- .../{spam_engine => spam_engine_}/akismet.rb | 0 .../lib/{spam_engine => spam_engine_}/base.rb | 0 .../{spam_engine => spam_engine_}/defensio.rb | 0 .../defensio/stats.rb | 0 .../lib/{spam_engine => spam_engine_}/none.rb | 0 .../{spam_engine => spam_engine_}/template.rb | 0 .../app/controllers/comments_controller.rb | 2 +- .../adva_comments/app/models/comment.rb | 24 +++- ...080712154718_add_spam_info_to_comments.rb} | 4 +- .../20080713161553_spam_reports_table.rb | 14 +++ vendor/engines/adva_spam/init.rb | 5 + vendor/engines/adva_spam/lib/spam_engine.rb | 4 + .../adva_spam/lib/spam_engine/filter.rb | 20 ++++ .../lib/spam_engine/filter/akismet.rb | 37 ++++++ .../adva_spam/lib/spam_engine/filter/base.rb | 42 +++++++ .../lib/spam_engine/filter/default.rb | 20 ++++ .../lib/spam_engine/filter/defensio.rb | 43 +++++++ .../adva_spam/lib/spam_engine/filter_chain.rb | 28 +++++ vendor/engines/adva_spam/lib/spam_report.rb | 3 + 34 files changed, 565 insertions(+), 335 deletions(-) delete mode 100644 spec/models/spam/akismet.rb delete mode 100644 spec/models/spam/defensio.rb delete mode 100644 spec/models/spam/none.rb delete mode 100644 spec/models/spam/spam.rb create mode 100644 spec/models/spam_engine/akismet.rb create mode 100644 spec/models/spam_engine/default.rb create mode 100644 spec/models/spam_engine/defensio.rb create mode 100644 spec/models/spam_engine/filter_chain.rb rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/akismet.rb (100%) rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/base.rb (100%) rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/defensio.rb (100%) rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/defensio/stats.rb (100%) rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/none.rb (100%) rename vendor/engines/adva_cms/lib/{spam_engine => spam_engine_}/template.rb (100%) rename vendor/engines/adva_comments/db/migrate/{20080712154717_add_spam_info_to_comments.rb => 20080712154718_add_spam_info_to_comments.rb} (54%) create mode 100644 vendor/engines/adva_spam/db/migrate/20080713161553_spam_reports_table.rb create mode 100644 vendor/engines/adva_spam/init.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter/akismet.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter/base.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter/default.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter/defensio.rb create mode 100644 vendor/engines/adva_spam/lib/spam_engine/filter_chain.rb create mode 100644 vendor/engines/adva_spam/lib/spam_report.rb diff --git a/TODO b/TODO index 006398163..309eb5a81 100644 --- a/TODO +++ b/TODO @@ -3,8 +3,6 @@ This is just a collection of ideas, notes, ressource and less important stuff. Please refer to the issue tracker at http://artweb-design.lighthouseapp.com/projects/13992-adva_cms/overview -francois@teksol.info - [theme] separate login and admin/login pages (same thing, different layout) ? [feature] admin section: have an activity center/dashboard where engines can register their @@ -213,6 +211,63 @@ check fleximage http://github.com/Squeegy/fleximage/tree/master check http://github.com/walf443/classx/tree/master check http://github.com/tokumine/awesome_nested_set/tree/master + + +# Spam protection + +Users should be able to add and configure custom spam filters easily. +Users should be able to configure the applications response to spam filter +results flexibly. + +Spam filters can be registered to the application (e.g. from a plugin). There +are built-in spam filters for Akismet and Defensio as well as a Default Filter. + +For a Site (maybe later: for a Section) one can activate and configure which +spam filters are active. One can also change the order in which the spam filters +will be run. + +When a Comment is created a spam filter chain is assembled and run. Each filter +can add to the spam analysis results. The results are then accumulated to a +final spaminess value. The individual analysis results can be saved for +statistical reports. + +Filters can decide to halt the execution of futher filters on certain conditions. +E.g. when the user is logged in with a certain role a filter might skip executing +additional filters and report a spaminess of 0. + +After the filter chain has been run the application judges how to handle the +comment. It might be approved, pushed to the moderation queue, marked as spam +or even deleted. + +In any case when a Comment is eventually marked as ham or spam by either the +application or the user every filter is given the chance to report back to +their backend if the spam status does not match their initial analysis. + +class Comment + acts_as_spam_report_set +end + +acts_as_spam_report_set + has_many :spam_reports + attr :spaminess + + def add_spam_report(filter, result) + spam_reports << SpamReport.create! :priority => filter.priority, ... + end + + def update_spaminess + sum = spam_reports.inject(0){|report, sum| sum += report.spaminess } + self.spaminess = sum / spam_reports.size + end + +class SpamReport + attr :priority + attr :engine + attr :spaminess + attr :data +end + + # Sanitizing input contact xss_terminate developer regarding improvements/refactorings diff --git a/db/schema.rb b/db/schema.rb index 57125beb8..eabfb3c73 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -107,7 +107,7 @@ t.integer "approved", :default => 0, :null => false t.datetime "created_at" t.datetime "updated_at" - t.string "spam_info" + t.text "spam_info" end create_table "content_versions", :force => true do |t| @@ -229,6 +229,14 @@ t.text "spam_options" end + create_table "spam_reports", :force => true do |t| + t.integer "subject_id" + t.string "subject_type" + t.string "engine" + t.float "spaminess" + t.text "data" + end + create_table "taggings", :force => true do |t| t.integer "tag_id" t.integer "taggable_id" diff --git a/spec/controllers/comments_controller_spec.rb b/spec/controllers/comments_controller_spec.rb index d0d114348..fb19debce 100755 --- a/spec/controllers/comments_controller_spec.rb +++ b/spec/controllers/comments_controller_spec.rb @@ -58,7 +58,7 @@ it "checks the comment's spaminess" do url = "http://test.host/sections/1/articles/an-article" - @comment.should_receive(:check_spam).with(url, :authenticated => false) + @comment.should_receive(:check_approval).with(url, :authenticated => false) act! end end diff --git a/spec/models/comment_spec.rb b/spec/models/comment_spec.rb index 801f2b5da..846ddc875 100755 --- a/spec/models/comment_spec.rb +++ b/spec/models/comment_spec.rb @@ -153,34 +153,37 @@ describe Comment, "spam control" do before :each do - @section = stub('section', :check_comment => {:spam => false}, :approve_comments? => false) + @report = SpamReport.new(:engine => name, :spaminess => 0, :data => {:spam => false, :spaminess => 0, :signature => 'signature'}) + @spam_engine = stub('spam_engine', :check_comment => @report) + @section = stub('section', :spam_engine => @spam_engine, :approve_comments? => false) + @comment = Comment.new @comment.stub!(:section).and_return @section - @url = 'http://www.domain.org/an-article' + @context = {:url => 'http://www.domain.org/an-article'} end - describe "#check_spam" do + describe "#check_approval" do it "saves the spam info hash as returned by Section#check_comment" do @comment.should_receive(:update_attributes).with hash_including(:spam_info => {:spam => false}) - @comment.check_spam @url + @comment.check_approval @context end it "approves the comment when the spam info hash contains :spam => false" do @section.stub!(:check_comment).and_return :spam => false @comment.should_receive(:update_attributes).with hash_including(:approved => true) - @comment.check_spam @url + @comment.check_approval @context end it "disapproves the comment when the spam info hash contains :spam => true" do @section.stub!(:check_comment).and_return :spam => true @comment.should_receive(:update_attributes).with hash_including(:approved => false) - @comment.check_spam @url + @comment.check_approval @context end it "approves the comment when Section#approve_comments? is true" do @section.stub!(:approve_comments?).and_return true @comment.should_receive(:update_attributes).with hash_including(:approved => true) - @comment.check_spam @url + @comment.check_approval @context end end @@ -194,20 +197,20 @@ # describe '#check_comment' do # it "approves the comment when the site's spam_option :engine is 'None' and approve_comments is true" do # sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => true} - # @comment.check_spam('http://www.example.org/an-article', @comment) + # @comment.check_approval('http://www.example.org/an-article', @comment) # @comment.approved?.should be_true # end # # it "does not approve the comment when the site's spam_option :engine is 'None' and approve_comments is not true" do # sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => false} - # @comment.check_spam('http://www.example.org/an-article', @comment) + # @comment.check_approval('http://www.example.org/an-article', @comment) # @comment.approved?.should be_false # end # # it "calls #check_comment on the None SpamEngine when the site's spam_option :engine is 'None'" do # sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => false} # @comment.section.site.spam_engine.should be_instance_of(SpamEngine::None) - # @comment.check_spam('http://www.example.org/an-article', @comment) + # @comment.check_approval('http://www.example.org/an-article', @comment) # @comment.spam_info.should == {} # end # end diff --git a/spec/models/spam/akismet.rb b/spec/models/spam/akismet.rb deleted file mode 100644 index d4ea9180b..000000000 --- a/spec/models/spam/akismet.rb +++ /dev/null @@ -1,100 +0,0 @@ -require File.dirname(__FILE__) + '/../../spec_helper' - -describe 'Spam engines', 'the Akismet engine' do - before :each do - @comment = Comment.new - @akismet = stub("akismet", :check_comment => false) - Viking::Akismet.stub!(:new).and_return(@akismet) - @url = 'http://www.example.org/an-article' - end - - def engine - @site.spam_engine - end - - describe 'when properly configured' do - before :each do - @site = Site.new :spam_options => {:engine => 'Akismet', 'Akismet' => {:akismet_key => 'key', :akismet_url => 'http://domain.com'}} - end - - it "is valid?" do - engine.valid?.should be_true - end - - it "instantiates a Viking Akismet backend when calling #check_comment" do - Viking::Akismet.should_receive(:new).and_return(@akismet) - engine.check_comment(@url, @comment) - end - - it "#check_comment returns a spam_info hash with :spam => false when the backend returned true" do - @akismet.stub!(:check_comment).and_return true - engine.check_comment(@url, @comment).should == {:spam => false} - end - - it "#check_comment returns a spam_info hash with :spam => true when the backend returned false" do - @akismet.stub!(:check_comment).and_return false - engine.check_comment(@url, @comment).should == {:spam => true} - end - end - - describe 'when the akismet key is missing' do - before :each do - @site = Site.new :spam_options => {:engine => 'Akismet', 'Akismet' => {:akismet_url => 'http://domain.com'}} - end - - it 'is not valid' do - engine.valid?.should be_false - end - - it 'raises NotConfigured when calling #check_comment' do - lambda { engine.check_comment(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - end - - describe 'when the akismet url is missing' do - before :each do - @site = Site.new :spam_options => {:engine => 'Akismet', 'Akismet' => {:akismet_key => 'key'}} - end - - it 'is not valid' do - engine.valid?.should be_false - end - - it 'raises NotConfigured when calling #check_comment' do - lambda { engine.check_comment(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - - it 'raises NotConfigured when calling #mark_as_ham' do - lambda { engine.mark_as_ham(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - - it 'raises NotConfigured when calling #mark_as_spam' do - lambda { engine.mark_as_spam(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - end - - # before :each do - # @comment = comments(:a_comment) - # @comment.author = anonymouses(:an_anonymous) # wtf - # @comment.commentable = contents(:an_article) - # - # http = Net::HTTP.new("url") - # Net::HTTP.stub!(:new).and_return(http) - # - # @options = { :permalink => "http://www.example.org/an-article", - # :user_ip => '1.1.1.1', - # :user_agent => 'the-agent', - # :referrer => 'the-referer', - # :comment_author => "anonymous", - # :comment_author_email => "anonymous@email.org", - # :comment_author_url => "http://www.example.org", - # :comment_content => "comment body" } - # end - # - # it "calls #check_comment on the Akismet Viking engine when the site's spam_option :engine is 'Akismet'" do - # sites(:site_1).update_attributes :spam_options => {:engine => 'Akismet', 'Akismet' => {:akismet_key => 'key', :akismet_url => 'http://domain.com'}} - # @comment.section.site.spam_engine.send(:akismet).should_receive(:check_comment).with(@options).and_return true - # @comment.check_spam('http://www.example.org/an-article', @comment) - # @comment.spam_info.should == {:spam => false} - # end -end \ No newline at end of file diff --git a/spec/models/spam/defensio.rb b/spec/models/spam/defensio.rb deleted file mode 100644 index 4f59fcf5c..000000000 --- a/spec/models/spam/defensio.rb +++ /dev/null @@ -1,107 +0,0 @@ -require File.dirname(__FILE__) + '/../../spec_helper' - -describe 'Spam engines', 'the Defensio engine' do - before :each do - article = Article.new :published_at => Time.now - @comment = Comment.new :commentable => article - @defensio = stub("defensio", :check_comment => false) - Viking::Defensio.stub!(:new).and_return(@defensio) - @url = 'http://www.example.org/an-article' - end - - def engine - @site.spam_engine - end - - describe 'when properly configured' do - before :each do - @site = Site.new :spam_options => {:engine => 'Defensio', 'Defensio' => {:defensio_key => 'key', :defensio_url => 'http://domain.com'}} - end - - it "is valid?" do - engine.valid?.should be_true - end - - it "instantiates a Viking Defensio backend when calling #check_comment" do - Viking::Defensio.should_receive(:new).and_return(@defensio) - engine.check_comment(@url, @comment) - end - - it "#check_comment returns a spam_info hash from the backend" do - @defensio.stub!(:check_comment).and_return :spam => false - engine.check_comment(@url, @comment).should == {:spam => false} - end - - it "#check_comment returns an empty hash when the backend returned false" do - @defensio.stub!(:check_comment).and_return false - engine.check_comment(@url, @comment).should == {} - end - end - - describe 'when the defensio key is missing' do - before :each do - @site = Site.new :spam_options => {:engine => 'Defensio', 'Defensio' => {:defensio_url => 'http://domain.com'}} - end - - it 'is not valid' do - engine.valid?.should be_false - end - - it 'raises NotConfigured when calling #check_comment' do - lambda { engine.check_comment(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - end - - describe 'when the defensio url is missing' do - before :each do - @site = Site.new :spam_options => {:engine => 'Defensio', 'Defensio' => {:defensio_key => 'key'}} - end - - it 'is not valid' do - engine.valid?.should be_false - end - - it 'raises NotConfigured when calling #check_comment' do - lambda { engine.check_comment(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - - it 'raises NotConfigured when calling #mark_as_ham' do - lambda { engine.mark_as_ham(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - - it 'raises NotConfigured when calling #mark_as_spam' do - lambda { engine.mark_as_spam(@url, @comment) }.should raise_error(SpamEngine::NotConfigured) - end - end -end - -# describe 'Spam engines' do -# fixtures :sites, :sections, :contents, :comments, :anonymouses -# -# before :each do -# @comment = comments(:a_comment) -# @comment.author = anonymouses(:an_anonymous) # wtf -# @comment.commentable = contents(:an_article) -# -# http = Net::HTTP.new("url") -# Net::HTTP.stub!(:new).and_return(http) -# -# @options = { :permalink => "http://www.example.org/an-article", -# :user_ip => '1.1.1.1', -# :user_agent => 'the-agent', -# :referrer => 'the-referer', -# :comment_author => "anonymous", -# :comment_author_email => "anonymous@email.org", -# :comment_author_url => "http://www.example.org", -# :comment_content => "comment body" } -# end -# -# describe '#check_comment' do -# it "calls #check_comment on the Defensio Viking engine when the site's spam_option :engine is 'Defensio'" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'Defensio', 'Defensio' => {:defensio_key => 'key', :defensio_url => 'http://domain.com'}} -# @comment.section.site.spam_engine.send(:defensio).should_receive(:check_comment).with(@options).and_return :spam => false, :spaminess => 0.0, :signature => 'signature' -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.spam_info.should == {:spam => false, :spaminess => 0.0, :signature => 'signature'} -# end -# end -# end \ No newline at end of file diff --git a/spec/models/spam/none.rb b/spec/models/spam/none.rb deleted file mode 100644 index ba47e8bfd..000000000 --- a/spec/models/spam/none.rb +++ /dev/null @@ -1,20 +0,0 @@ -require File.dirname(__FILE__) + '/../../spec_helper' - -describe 'Spam engines', 'the None engine' do - before :each do - @comment = Comment.new - @url = 'http://www.example.org/an-article' - end - - def engine - @site.spam_engine - end - - it "is valid?" do - engine.valid?.should be_true - end - - it "#check_comment returns an empty spam_info hash" do - engine.check_comment(@url, @comment).should == {} - end -end \ No newline at end of file diff --git a/spec/models/spam/spam.rb b/spec/models/spam/spam.rb deleted file mode 100644 index 9abada937..000000000 --- a/spec/models/spam/spam.rb +++ /dev/null @@ -1,70 +0,0 @@ -# require File.dirname(__FILE__) + '/../spec_helper' -# -# describe 'Spam engines' do -# fixtures :sites, :sections, :contents, :comments, :anonymouses -# -# before :each do -# @comment = comments(:a_comment) -# @comment.author = anonymouses(:an_anonymous) # wtf -# @comment.commentable = contents(:an_article) -# -# http = Net::HTTP.new("url") -# Net::HTTP.stub!(:new).and_return(http) -# -# @akismet_options = { :permalink => "http://www.example.org/an-article", -# :user_ip => '1.1.1.1', -# :user_agent => 'the-agent', -# :referrer => 'the-referer', -# :comment_author => "anonymous", -# :comment_author_email => "anonymous@email.org", -# :comment_author_url => "http://www.example.org", -# :comment_content => "comment body" } -# -# @defensio_options = { :permalink => "http://www.example.org/an-article", -# :user_ip => '1.1.1.1', -# :referrer => 'the-referer', -# :comment_author => "anonymous", -# :comment_author_email => "anonymous@email.org", -# :comment_author_url => "http://www.example.org", -# :comment_content => "comment body", -# :article_date => @comment.commentable.published_at, -# :comment_type => "comment", -# :user_logged_in => nil, -# :trusted_user => nil } -# end -# -# describe '#check_comment' do -# it "approves the comment when the site's spam_option :engine is 'None' and approve_comments is true" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => true} -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.approved?.should be_true -# end -# -# it "does not approve the comment when the site's spam_option :engine is 'None' and approve_comments is not true" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => false} -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.approved?.should be_false -# end -# -# it "calls #check_comment on the None SpamEngine when the site's spam_option :engine is 'None'" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'None', :approve_comments => false} -# @comment.section.site.spam_engine.should be_instance_of(SpamEngine::None) -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.spam_info.should == {} -# end -# -# it "calls #check_comment on the Akismet Viking engine when the site's spam_option :engine is 'Akismet'" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'Akismet', 'Akismet' => {:akismet_key => 'key', :akismet_url => 'http://domain.com'}} -# @comment.section.site.spam_engine.send(:akismet).should_receive(:check_comment).with(@akismet_options).and_return true -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.spam_info.should == {:spam => false} -# end -# -# it "calls #check_comment on the Defensio Viking engine when the site's spam_option :engine is 'Defensio'" do -# sites(:site_1).update_attributes :spam_options => {:engine => 'Defensio', 'Defensio' => {:defensio_key => 'key', :defensio_url => 'http://domain.com'}} -# @comment.section.site.spam_engine.send(:defensio).should_receive(:check_comment).with(@defensio_options).and_return :spam => false, :spaminess => 0.0, :signature => 'signature' -# @comment.check_spam('http://www.example.org/an-article', @comment) -# @comment.spam_info.should == {:spam => false, :spaminess => 0.0, :signature => 'signature'} -# end -# end -# end \ No newline at end of file diff --git a/spec/models/spam_engine/akismet.rb b/spec/models/spam_engine/akismet.rb new file mode 100644 index 000000000..fef76e97d --- /dev/null +++ b/spec/models/spam_engine/akismet.rb @@ -0,0 +1,82 @@ +require File.dirname(__FILE__) + '/../../spec_helper' + +describe 'SpamEngine:', 'the Akismet Filter' do + before :each do + @akismet = SpamEngine::Filter::Akismet.new :key => 'akismet key', :url => 'akismet url', :priority => 2 + @comment = Comment.new + @context = {:url => 'http://domain.org/an-article'} + end + + it "returns the key" do + @akismet.key.should == 'akismet key' + end + + it "returns the url" do + @akismet.url.should == 'akismet url' + end + + it "returns the priority" do + @akismet.priority.should == 2 + end + + describe "when properly configured" do + before :each do + @akismet = SpamEngine::Filter::Akismet.new :key => 'akismet key', :url => 'akismet url', :priority => 2 + Viking::Akismet.stub!(:new).and_return stub("viking", :check_comment => false) + end + + describe "#check_comment" do + it "instantiates a Viking Akismet backend" do + Viking::Akismet.should_receive(:new).and_return stub("viking", :check_comment => false) + @akismet.check_comment(@comment, @context) + end + + it "returns a new SpamReport populated with the results from the backend" do + report = @akismet.check_comment(@comment, @context) + report.should be_instance_of(SpamReport) + report.engine.should == 'Akismet' + report.spaminess.should == 100 + end + end + end + + describe 'when the key is missing' do + before :each do + @akismet = SpamEngine::Filter::Akismet.new :url => 'akismet url', :priority => 2 + end + + it 'raises NotConfigured when calling #check_comment' do + lambda { @akismet.check_comment(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_ham' do + lambda { @akismet.mark_as_ham(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_spam' do + lambda { @akismet.mark_as_spam(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'the raised exception lists the error' + end + + describe 'when the url is missing' do + before :each do + @akismet = SpamEngine::Filter::Akismet.new :key => 'akismet key', :priority => 2 + end + + it 'raises NotConfigured when calling #check_comment' do + lambda { @akismet.check_comment(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_ham' do + lambda { @akismet.mark_as_ham(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_spam' do + lambda { @akismet.mark_as_spam(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'the raised exception lists the error' + end +end \ No newline at end of file diff --git a/spec/models/spam_engine/default.rb b/spec/models/spam_engine/default.rb new file mode 100644 index 000000000..541a806a4 --- /dev/null +++ b/spec/models/spam_engine/default.rb @@ -0,0 +1,38 @@ +require File.dirname(__FILE__) + '/../../spec_helper' + +describe 'SpamEngine:', 'the Default Filter' do + before :each do + @filter = SpamEngine::Filter::Default.new :priority => 1, :always_ham => false, :authenticated_ham => false + @comment = Comment.new + @context = {:url => 'http://domain.org/an-article'} + end + + it "returns the priority" do + @filter.priority.should == 1 + end + + describe "#check_comment" do + it "returns a new SpamReport" do + report = @filter.check_comment(@comment, @context) + report.should be_instance_of(SpamReport) + report.engine.should == 'Default' + end + + it "reports a spaminess of 0.0 if :always_ham is true" do + @filter.options[:always_ham] = true + report = @filter.check_comment(@comment, @context) + report.spaminess.should == 0.0 + end + + it "reports a spaminess of 0.0 if :authenticated_ham is true" do + @filter.options[:authenticated_ham] = true + report = @filter.check_comment(@comment, @context) + report.spaminess.should == 0.0 + end + + it "reports a spaminess of 100.0 if neither :always_ham nor :authenticated_ham are true" do + report = @filter.check_comment(@comment, @context) + report.spaminess.should == 100.0 + end + end +end \ No newline at end of file diff --git a/spec/models/spam_engine/defensio.rb b/spec/models/spam_engine/defensio.rb new file mode 100644 index 000000000..2b57d133f --- /dev/null +++ b/spec/models/spam_engine/defensio.rb @@ -0,0 +1,85 @@ +require File.dirname(__FILE__) + '/../../spec_helper' + +describe 'SpamEngine:', 'the Defensio Filter' do + before :each do + @defensio = SpamEngine::Filter::Defensio.new :key => 'defensio key', :url => 'defensio url', :priority => 2 + @comment = Comment.new + @comment.stub!(:commentable).and_return stub('commentable', :published_at => Time.now) + @context = {:url => 'http://domain.org/an-article'} + end + + it "returns the key" do + @defensio.key.should == 'defensio key' + end + + it "returns the url" do + @defensio.url.should == 'defensio url' + end + + it "returns the priority" do + @defensio.priority.should == 2 + end + + describe "when properly configured" do + before :each do + @defensio = SpamEngine::Filter::Defensio.new :key => 'defensio key', :url => 'defensio url', :priority => 2 + @result = {:spam => false, :spaminess => 33.0, :signature => 'signature'} + Viking::Defensio.stub!(:new).and_return stub("viking", :check_comment => @result) + end + + describe "#check_comment" do + it "instantiates a Viking Defensio backend" do + Viking::Defensio.should_receive(:new).and_return stub("viking", :check_comment => @result) + @defensio.check_comment(@comment, @context) + end + + it "returns a new SpamReport populated with the results from the backend" do + report = @defensio.check_comment(@comment, @context) + report.should be_instance_of(SpamReport) + report.engine.should == 'Defensio' + report.spaminess.should == 33.0 + report.data.should == {:spam => false, :spaminess => 33.0, :signature => 'signature'} + end + end + end + + describe 'when the key is missing' do + before :each do + @defensio = SpamEngine::Filter::Defensio.new :url => 'defensio url', :priority => 2 + end + + it 'raises NotConfigured when calling #check_comment' do + lambda { @defensio.check_comment(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_ham' do + lambda { @defensio.mark_as_ham(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_spam' do + lambda { @defensio.mark_as_spam(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'the raised exception lists the error' + end + + describe 'when the url is missing' do + before :each do + @defensio = SpamEngine::Filter::Defensio.new :key => 'defensio key', :priority => 2 + end + + it 'raises NotConfigured when calling #check_comment' do + lambda { @defensio.check_comment(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_ham' do + lambda { @defensio.mark_as_ham(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'raises NotConfigured when calling #mark_as_spam' do + lambda { @defensio.mark_as_spam(@comment, @context) }.should raise_error(SpamEngine::NotConfigured) + end + + it 'the raised exception lists the error' + end +end \ No newline at end of file diff --git a/spec/models/spam_engine/filter_chain.rb b/spec/models/spam_engine/filter_chain.rb new file mode 100644 index 000000000..b4a2b69ed --- /dev/null +++ b/spec/models/spam_engine/filter_chain.rb @@ -0,0 +1,39 @@ +require File.dirname(__FILE__) + '/../../spec_helper' + +describe 'SpamEngine:', 'the FilterChain' do + before :each do + options = { + :default => {:approve_all => false, :priority => 1}, + :akismet => {:key => 'akismet key', :url => 'akismet url', :priority => 2}, + :defensio => {:key => 'defensio key', :url => 'defensio url', :priority => 3} + } + @chain = SpamEngine::FilterChain.assemble options + @default, @akismet, @defensio = *@chain + + @comment = Comment.new + @context = {:url => 'http://domain.org/an-article'} + end + + it "when called #assemble returns a filter chain with filters assembled" do + @default.should be_instance_of(SpamEngine::Filter::Default) + @default.options.should == {:approve_all => false, :priority => 1} + + @akismet.should be_instance_of(SpamEngine::Filter::Akismet) + @akismet.options.should == {:key => 'akismet key', :url => 'akismet url', :priority => 2} + + @defensio.should be_instance_of(SpamEngine::Filter::Defensio) + @defensio.options.should == {:key => 'defensio key', :url => 'defensio url', :priority => 3} + end + + it "when called #check_comment calls #check_comment on the filters in the correct order" do + @default.should_receive(:check_comment) do + @akismet.should_receive(:check_comment) do + @defensio.should_receive(:check_comment) + end + end + + @comment.stub!(:add_spam_report) + @chain.check_comment(@comment, @context) + end +end + diff --git a/spec/stubs/comment.rb b/spec/stubs/comment.rb index a6e347961..102354b7a 100644 --- a/spec/stubs/comment.rb +++ b/spec/stubs/comment.rb @@ -19,7 +19,7 @@ :frozen? => false, :role_authorizing => Role.build(:author), :commentable= => nil, - :check_spam => false, + :check_approval => false, :approved_changed? => false instance :comment diff --git a/vendor/engines/adva_cms/app/models/section.rb b/vendor/engines/adva_cms/app/models/section.rb index f6cc1cd18..d7aa45691 100644 --- a/vendor/engines/adva_cms/app/models/section.rb +++ b/vendor/engines/adva_cms/app/models/section.rb @@ -41,6 +41,8 @@ def roots validates_numericality_of :articles_per_page, :only_integer => true, :message => "can only be whole number." # TODO validates_inclusion_of :articles_per_page, :in => 1..30, :message => "can only be between 1 and 30." + delegate :spam_engine, :approve_comments?, :to => :site + class << self def register_type(type) @@types << type @@ -76,14 +78,6 @@ def accept_comments? comment_age.to_i > -1 end - def approve_comments? - site.approve_comments? - end - - def check_comment(url, comment, options = {}) - site.spam_engine.check_comment(url, comment, options) - end - protected def set_comment_age diff --git a/vendor/engines/adva_cms/init.rb b/vendor/engines/adva_cms/init.rb index 46208434f..93c50fb4d 100644 --- a/vendor/engines/adva_cms/init.rb +++ b/vendor/engines/adva_cms/init.rb @@ -17,9 +17,4 @@ TagList.delimiter = ' ' Tag.destroy_unused = true -Tag.class_eval do def to_param; name end end - -require 'spam_engine' -require 'spam_engine/none' -require 'spam_engine/akismet' -require 'spam_engine/defensio' \ No newline at end of file +Tag.class_eval do def to_param; name end end \ No newline at end of file diff --git a/vendor/engines/adva_cms/lib/spam_engine/akismet.rb b/vendor/engines/adva_cms/lib/spam_engine_/akismet.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/akismet.rb rename to vendor/engines/adva_cms/lib/spam_engine_/akismet.rb diff --git a/vendor/engines/adva_cms/lib/spam_engine/base.rb b/vendor/engines/adva_cms/lib/spam_engine_/base.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/base.rb rename to vendor/engines/adva_cms/lib/spam_engine_/base.rb diff --git a/vendor/engines/adva_cms/lib/spam_engine/defensio.rb b/vendor/engines/adva_cms/lib/spam_engine_/defensio.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/defensio.rb rename to vendor/engines/adva_cms/lib/spam_engine_/defensio.rb diff --git a/vendor/engines/adva_cms/lib/spam_engine/defensio/stats.rb b/vendor/engines/adva_cms/lib/spam_engine_/defensio/stats.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/defensio/stats.rb rename to vendor/engines/adva_cms/lib/spam_engine_/defensio/stats.rb diff --git a/vendor/engines/adva_cms/lib/spam_engine/none.rb b/vendor/engines/adva_cms/lib/spam_engine_/none.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/none.rb rename to vendor/engines/adva_cms/lib/spam_engine_/none.rb diff --git a/vendor/engines/adva_cms/lib/spam_engine/template.rb b/vendor/engines/adva_cms/lib/spam_engine_/template.rb similarity index 100% rename from vendor/engines/adva_cms/lib/spam_engine/template.rb rename to vendor/engines/adva_cms/lib/spam_engine_/template.rb diff --git a/vendor/engines/adva_comments/app/controllers/comments_controller.rb b/vendor/engines/adva_comments/app/controllers/comments_controller.rb index 76173b4ad..0d3bd0952 100644 --- a/vendor/engines/adva_comments/app/controllers/comments_controller.rb +++ b/vendor/engines/adva_comments/app/controllers/comments_controller.rb @@ -25,7 +25,7 @@ def create params[:comment].delete(:approved) # TODO use attr_protected api @comment = @commentable.comments.build(params[:comment]) if @comment.save - @comment.check_spam content_url(@comment.commentable), {:authenticated => authenticated?} + @comment.check_approval content_url(@comment.commentable), {:authenticated => authenticated?} flash[:notice] = "Thank you for your comment!" redirect_to comment_path(@comment) else diff --git a/vendor/engines/adva_comments/app/models/comment.rb b/vendor/engines/adva_comments/app/models/comment.rb index 47c644e12..572713c99 100644 --- a/vendor/engines/adva_comments/app/models/comment.rb +++ b/vendor/engines/adva_comments/app/models/comment.rb @@ -37,18 +37,30 @@ def spam_info read_attribute(:spam_info) || {} end + has_many :spam_reports, :as => :subject + + def spam_threshold + 50 # TODO have a config option on site for this + end + def ham? - spam_info[:spam] == false + spaminess < spam_threshold end def spam? - spam_info[:spam] == true + spaminess >= spam_threshold + end + + def check_approval(context = {}) + spam_reports << section.spam_engine.check_comment(self, context) + update_spaminess + self.approved = ham? + save! end - def check_spam(url, options = {}) - spam_info = section.check_comment(url, self, options) - approved = section.approve_comments? || !!spam_info[:spam] - self.update_attributes :spam_info => spam_info, :approved => approved + def update_spaminess + sum = spam_reports(true).inject(0){|report, sum| sum + report.spaminess } + self.spaminess = sum > 0 ? sum / spam_reports.count : 0 end protected diff --git a/vendor/engines/adva_comments/db/migrate/20080712154717_add_spam_info_to_comments.rb b/vendor/engines/adva_comments/db/migrate/20080712154718_add_spam_info_to_comments.rb similarity index 54% rename from vendor/engines/adva_comments/db/migrate/20080712154717_add_spam_info_to_comments.rb rename to vendor/engines/adva_comments/db/migrate/20080712154718_add_spam_info_to_comments.rb index 9de438267..57332f7f3 100644 --- a/vendor/engines/adva_comments/db/migrate/20080712154717_add_spam_info_to_comments.rb +++ b/vendor/engines/adva_comments/db/migrate/20080712154718_add_spam_info_to_comments.rb @@ -1,9 +1,9 @@ class AddSpamInfoToComments < ActiveRecord::Migration def self.up - add_column :comments, :spam_info, :text + add_column :comments, :spaminess, :text end def self.down - remove_column :comments, :spam_info + remove_column :comments, :spaminess end end diff --git a/vendor/engines/adva_spam/db/migrate/20080713161553_spam_reports_table.rb b/vendor/engines/adva_spam/db/migrate/20080713161553_spam_reports_table.rb new file mode 100644 index 000000000..23faacb7c --- /dev/null +++ b/vendor/engines/adva_spam/db/migrate/20080713161553_spam_reports_table.rb @@ -0,0 +1,14 @@ +class SpamReportsTable < ActiveRecord::Migration + def self.up + create_table :spam_reports do |t| + t.references :subject, :polymorphic => true + t.string :engine + t.float :spaminess + t.text :data + end + end + + def self.down + drop_table :spam_reports + end +end diff --git a/vendor/engines/adva_spam/init.rb b/vendor/engines/adva_spam/init.rb new file mode 100644 index 000000000..25efcf4e9 --- /dev/null +++ b/vendor/engines/adva_spam/init.rb @@ -0,0 +1,5 @@ +# require 'spam_engine' +# require 'spam_engine/filter' +# require 'spam_engine/filter/default' +# require 'spam_engine/filter/akismet' +# require 'spam_engine/filter/defensio' \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine.rb b/vendor/engines/adva_spam/lib/spam_engine.rb new file mode 100644 index 000000000..4b642d27f --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine.rb @@ -0,0 +1,4 @@ +module SpamEngine + class NotConfigured < RuntimeError + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter.rb b/vendor/engines/adva_spam/lib/spam_engine/filter.rb new file mode 100644 index 000000000..e0003f6c5 --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter.rb @@ -0,0 +1,20 @@ +module SpamEngine + module Filter + mattr_accessor :filters + @@filters = [] + + class << self + def register(klass) + @@filters << klass.name + end + + def names + filters.map &:demodulize + end + + def create(type, options) + "SpamEngine::Filter::#{type.to_s.classify}".constantize.new options + end + end + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter/akismet.rb b/vendor/engines/adva_spam/lib/spam_engine/filter/akismet.rb new file mode 100644 index 000000000..72df03d34 --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter/akismet.rb @@ -0,0 +1,37 @@ +module SpamEngine + module Filter + class Akismet < Base + SpamEngine::Filter.register self + + def check_comment(comment, context = {}) + is_ham = backend.check_comment(comment_options(comment, context)) + SpamReport.new(:engine => name, :spaminess => (is_ham ? 0 : 100)) + end + + def mark_as_ham(comment, context = {}) + key && url + end + + def mark_as_spam(comment, context = {}) + key && url + end + + protected + + def backend + @backend ||= Viking.connect("akismet", :api_key => key, :blog => url) + end + + def comment_options(comment, context) + { :permalink => context[:url], + :user_ip => comment.author_ip, + :user_agent => comment.author_agent, + :referrer => comment.author_referer, + :comment_author => comment.author_name, + :comment_author_email => comment.author_email, + :comment_author_url => comment.author_homepage, + :comment_content => comment.body } + end + end + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter/base.rb b/vendor/engines/adva_spam/lib/spam_engine/filter/base.rb new file mode 100644 index 000000000..805530b83 --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter/base.rb @@ -0,0 +1,42 @@ +module SpamEngine + module Filter + class Base + attr_accessor :options + + def initialize(options = {}) + @options = options + end + + def name + self.class.name.demodulize + end + + def valid?(*args) + raise "not implemented. implement #valid? in your filter class." + end + + def check_comment(*args) + raise "not implemented. implement #check_comment in your filter class." + end + + def mark_as_ham(*args) + raise "not implemented. implement #mark_as_ham in your filter class." + end + + def mark_as_spam(*args) + raise "not implemented. implement #mark_as_spam in your filter class." + end + + def respond_to?(method) + return true if options.has_key?(method) + super + end + + def method_missing(method, *args) + return options[method] if respond_to?(method) + raise NotConfigured if [:key, :url].include? method + super + end + end + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter/default.rb b/vendor/engines/adva_spam/lib/spam_engine/filter/default.rb new file mode 100644 index 000000000..d9db4224f --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter/default.rb @@ -0,0 +1,20 @@ +module SpamEngine + module Filter + class Default < Base + SpamEngine::Filter.register self + + def check_comment(comment, context = {}) + spaminess = always_ham ? 0 : authenticated_ham ? 0 : 100 + SpamReport.new :engine => name, :spaminess => spaminess + end + + def mark_as_ham(comment, context = {}) + # nothing to do + end + + def mark_as_spam(comment, context = {}) + # nothing to do + end + end + end +end diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter/defensio.rb b/vendor/engines/adva_spam/lib/spam_engine/filter/defensio.rb new file mode 100644 index 000000000..44882f84d --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter/defensio.rb @@ -0,0 +1,43 @@ +module SpamEngine + module Filter + class Defensio < Base + SpamEngine::Filter.register self + + def check_comment(comment, context = {}) + result = backend.check_comment(comment_options(comment, context)) + SpamReport.new(:engine => name, :spaminess => result[:spaminess], :data => result) + end + + def mark_as_ham(comment, context = {}) + key && url + end + + def mark_as_spam(comment, context = {}) + key && url + end + + protected + + def backend + @backend ||= Viking.connect("defensio", :api_key => key, :blog => url) + end + + def comment_options(comment, context) + { # Required parameters + :user_ip => comment.author_ip, + :article_date => comment.commentable.published_at, + :comment_author => comment.author_name, + :comment_type => "comment", + + # Optional parameters + :permalink => context[:url], + :comment_content => comment.body, + :comment_author_email => comment.author_email, + :comment_author_url => comment.author_homepage, + :referrer => comment.author_referer, + :user_logged_in => context[:authenticated], + :trusted_user => context[:authenticated] } + end + end + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_engine/filter_chain.rb b/vendor/engines/adva_spam/lib/spam_engine/filter_chain.rb new file mode 100644 index 000000000..35109b2bc --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_engine/filter_chain.rb @@ -0,0 +1,28 @@ +module SpamEngine + class FilterChain < Array + class << self + def assemble(options) + self.new options.map{|type, filter| SpamEngine::Filter.create(type, filter) } + end + end + + def initialize(filters) + super + sort_by_priority! + end + + def check_comment(comment, context = {}) + run :check_comment, comment, context + end + + protected + + def run(method, *args) + each{|filter| filter.send(method, *args) } # TODO catch exceptions + end + + def sort_by_priority! + self.sort!{|left, right| left.priority <=> right.priority } + end + end +end \ No newline at end of file diff --git a/vendor/engines/adva_spam/lib/spam_report.rb b/vendor/engines/adva_spam/lib/spam_report.rb new file mode 100644 index 000000000..4841a8ffa --- /dev/null +++ b/vendor/engines/adva_spam/lib/spam_report.rb @@ -0,0 +1,3 @@ +class SpamReport < ActiveRecord::Base + belongs_to :subject, :polymorphic => true +end \ No newline at end of file