commit
0bd61df34c
|
@ -36,15 +36,24 @@ class Topic < ActiveRecord::Base
|
||||||
rate_limit :limit_topics_per_day
|
rate_limit :limit_topics_per_day
|
||||||
rate_limit :limit_private_messages_per_day
|
rate_limit :limit_private_messages_per_day
|
||||||
|
|
||||||
validate :title_quality
|
before_validation :sanitize_title
|
||||||
validates_presence_of :title
|
|
||||||
validate :title, -> { SiteSetting.topic_title_length.include? :length }
|
validates :title, :presence => true,
|
||||||
|
:length => { :in => SiteSetting.topic_title_length,
|
||||||
|
:allow_blank => true },
|
||||||
|
:quality_title => { :unless => :private_message? },
|
||||||
|
:unique_among => { :unless => Proc.new { |t| (SiteSetting.allow_duplicate_topic_titles? || t.private_message?) },
|
||||||
|
:message => :has_already_been_used,
|
||||||
|
:allow_blank => true,
|
||||||
|
:case_sensitive => false,
|
||||||
|
:collection => Proc.new{ Topic.listable_topics } }
|
||||||
|
|
||||||
|
after_validation do
|
||||||
|
self.title = TextCleaner.clean_title(TextSentinel.title_sentinel(title).text) if errors[:title].empty?
|
||||||
|
end
|
||||||
|
|
||||||
serialize :meta_data, ActiveRecord::Coders::Hstore
|
serialize :meta_data, ActiveRecord::Coders::Hstore
|
||||||
|
|
||||||
before_validation :sanitize_title
|
|
||||||
validate :unique_title
|
|
||||||
|
|
||||||
belongs_to :category
|
belongs_to :category
|
||||||
has_many :posts
|
has_many :posts
|
||||||
has_many :topic_allowed_users
|
has_many :topic_allowed_users
|
||||||
|
@ -142,22 +151,6 @@ class Topic < ActiveRecord::Base
|
||||||
RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i)
|
RateLimiter.new(user, "pms-per-day:#{Date.today.to_s}", SiteSetting.max_private_messages_per_day, 1.day.to_i)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Validate unique titles if a site setting is set
|
|
||||||
def unique_title
|
|
||||||
return if SiteSetting.allow_duplicate_topic_titles?
|
|
||||||
|
|
||||||
# Let presence validation catch it if it's blank
|
|
||||||
return if title.blank?
|
|
||||||
|
|
||||||
# Private messages can be called whatever they want
|
|
||||||
return if private_message?
|
|
||||||
|
|
||||||
finder = Topic.listable_topics.where("lower(title) = ?", title.downcase)
|
|
||||||
finder = finder.where("id != ?", self.id) if self.id.present?
|
|
||||||
|
|
||||||
errors.add(:title, I18n.t(:has_already_been_used)) if finder.exists?
|
|
||||||
end
|
|
||||||
|
|
||||||
def fancy_title
|
def fancy_title
|
||||||
return title unless SiteSetting.title_fancy_entities?
|
return title unless SiteSetting.title_fancy_entities?
|
||||||
|
|
||||||
|
@ -168,19 +161,6 @@ class Topic < ActiveRecord::Base
|
||||||
Redcarpet::Render::SmartyPants.render(title)
|
Redcarpet::Render::SmartyPants.render(title)
|
||||||
end
|
end
|
||||||
|
|
||||||
def title_quality
|
|
||||||
# We don't care about quality on private messages
|
|
||||||
return if private_message?
|
|
||||||
|
|
||||||
sentinel = TextSentinel.title_sentinel(title)
|
|
||||||
if sentinel.valid?
|
|
||||||
# clean up the title
|
|
||||||
self.title = TextCleaner.clean_title(sentinel.text)
|
|
||||||
else
|
|
||||||
errors.add(:title, I18n.t(:is_invalid))
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def sanitize_title
|
def sanitize_title
|
||||||
if self.title.present?
|
if self.title.present?
|
||||||
self.title = sanitize(title, tags: [], attributes: [])
|
self.title = sanitize(title, tags: [], attributes: [])
|
||||||
|
|
|
@ -31,7 +31,8 @@ module Discourse
|
||||||
end
|
end
|
||||||
|
|
||||||
# Custom directories with classes and modules you want to be autoloadable.
|
# Custom directories with classes and modules you want to be autoloadable.
|
||||||
config.autoload_paths += %W(#{config.root}/app/serializers)
|
config.autoload_paths += Dir["#{config.root}/app/serializers"]
|
||||||
|
config.autoload_paths += Dir["#{config.root}/lib/validators/"]
|
||||||
|
|
||||||
# Only load the plugins named here, in the order given (default is alphabetical).
|
# Only load the plugins named here, in the order given (default is alphabetical).
|
||||||
# :all can be used as a placeholder for all plugins not explicitly named.
|
# :all can be used as a placeholder for all plugins not explicitly named.
|
||||||
|
|
|
@ -100,6 +100,9 @@ en:
|
||||||
post:
|
post:
|
||||||
raw: "Body"
|
raw: "Body"
|
||||||
errors:
|
errors:
|
||||||
|
messages:
|
||||||
|
is_invalid: "is invalid; try to be a little more descriptive"
|
||||||
|
has_already_been_used: "has already been used"
|
||||||
models:
|
models:
|
||||||
topic:
|
topic:
|
||||||
attributes:
|
attributes:
|
||||||
|
|
|
@ -7,7 +7,7 @@ class TextSentinel
|
||||||
|
|
||||||
def initialize(text, opts=nil)
|
def initialize(text, opts=nil)
|
||||||
@opts = opts || {}
|
@opts = opts || {}
|
||||||
@text = text.encode('UTF-8', invalid: :replace, undef: :replace, replace: '') if text.present?
|
@text = text.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '')
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.non_symbols_regexp
|
def self.non_symbols_regexp
|
||||||
|
@ -26,28 +26,26 @@ class TextSentinel
|
||||||
|
|
||||||
# Entropy is a number of how many unique characters the string needs.
|
# Entropy is a number of how many unique characters the string needs.
|
||||||
def entropy
|
def entropy
|
||||||
return 0 if @text.blank?
|
@entropy ||= @text.to_s.strip.split('').uniq.size
|
||||||
@entropy ||= @text.strip.each_char.to_a.uniq.size
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def valid?
|
def valid?
|
||||||
# Blank strings are not valid
|
# Blank strings are not valid
|
||||||
return false if @text.blank? || @text.strip.blank?
|
@text.present? &&
|
||||||
|
|
||||||
# Entropy check if required
|
# Minimum entropy if entropy check required
|
||||||
return false if @opts[:min_entropy].present? && (entropy < @opts[:min_entropy])
|
(@opts[:min_entropy].blank? || (entropy >= @opts[:min_entropy])) &&
|
||||||
|
|
||||||
# We don't have a comprehensive list of symbols, but this will eliminate some noise
|
# At least some non-symbol characters
|
||||||
non_symbols = @text.gsub(TextSentinel.non_symbols_regexp, '').size
|
# (We don't have a comprehensive list of symbols, but this will eliminate some noise)
|
||||||
return false if non_symbols == 0
|
(@text.gsub(TextSentinel.non_symbols_regexp, '').size > 0) &&
|
||||||
|
|
||||||
# Don't allow super long strings without spaces
|
# Don't allow super long words if there is a word length maximum
|
||||||
return false if @opts[:max_word_length] && @text =~ /\w{#{@opts[:max_word_length]},}(\s|$)/
|
(@opts[:max_word_length].blank? || @text.split(/\W/).map(&:size).max <= @opts[:max_word_length] ) &&
|
||||||
|
|
||||||
# We don't allow all upper case content in english
|
# We don't allow all upper case content in english
|
||||||
return false if (@text =~ /[A-Z]+/) && (@text == @text.upcase)
|
not((@text =~ /[A-Z]+/) && (@text == @text.upcase)) &&
|
||||||
|
|
||||||
# It is valid
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
require 'text_sentinel'
|
||||||
|
require 'text_cleaner'
|
||||||
|
|
||||||
|
class QualityTitleValidator < ActiveModel::EachValidator
|
||||||
|
def validate_each(record, attribute, value)
|
||||||
|
sentinel = TextSentinel.title_sentinel(value)
|
||||||
|
record.errors.add(attribute, :is_invalid) unless sentinel.valid?
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,21 @@
|
||||||
|
class UniqueAmongValidator < ActiveRecord::Validations::UniquenessValidator
|
||||||
|
def validate_each(record, attribute, value)
|
||||||
|
old_errors = record.errors[attribute].size
|
||||||
|
|
||||||
|
# look for any duplicates at all
|
||||||
|
super
|
||||||
|
|
||||||
|
new_errors = record.errors[attribute].size - old_errors
|
||||||
|
|
||||||
|
# do nothing further unless there were some duplicates.
|
||||||
|
unless new_errors == 0
|
||||||
|
# now look only in the collection we care about.
|
||||||
|
dupes = options[:collection].call.where("lower(#{attribute}) = ?", value.downcase)
|
||||||
|
dupes = dupes.where("id != ?", record.id) if record.persisted?
|
||||||
|
|
||||||
|
# pop off the error, if it was a false positive
|
||||||
|
record.errors[attribute].pop(new_errors) unless dupes.exists?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,73 @@
|
||||||
|
# encoding: UTF-8
|
||||||
|
|
||||||
|
require 'spec_helper'
|
||||||
|
require 'validators/quality_title_validator'
|
||||||
|
require 'ostruct'
|
||||||
|
|
||||||
|
module QualityTitleValidatorSpec
|
||||||
|
class Validatable < OpenStruct
|
||||||
|
include ActiveModel::Validations
|
||||||
|
validates :title, :quality_title => { :unless => :private_message? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "A record validated with QualityTitleValidator" do
|
||||||
|
let(:valid_title){ "hello this is my cool topic! welcome: all;" }
|
||||||
|
let(:short_title){ valid_title.slice(0, SiteSetting.min_topic_title_length - 1) }
|
||||||
|
let(:long_title ){ valid_title.center(SiteSetting.max_topic_title_length + 1, 'x') }
|
||||||
|
let(:xxxxx_title){ valid_title.gsub(/./,'x')}
|
||||||
|
|
||||||
|
subject(:topic){ QualityTitleValidatorSpec::Validatable.new }
|
||||||
|
|
||||||
|
before(:each) do
|
||||||
|
topic.stubs(:private_message? => false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows a regular title with a few ascii characters" do
|
||||||
|
topic.title = valid_title
|
||||||
|
topic.should be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows non ascii" do
|
||||||
|
topic.title = "Iñtërnâtiônàlizætiøn"
|
||||||
|
topic.should be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "allows anything in a private message" do
|
||||||
|
topic.stubs(:private_message? => true)
|
||||||
|
[short_title, long_title, xxxxx_title].each do |bad_title|
|
||||||
|
topic.title = bad_title
|
||||||
|
topic.should be_valid
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
it "strips a title when identifying length" do
|
||||||
|
topic.title = short_title.center(SiteSetting.min_topic_title_length + 1, ' ')
|
||||||
|
topic.should_not be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't allow a long title" do
|
||||||
|
topic.title = long_title
|
||||||
|
topic.should_not be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't allow a short title" do
|
||||||
|
topic.title = short_title
|
||||||
|
topic.should_not be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
it "doesn't allow a title of one repeated character" do
|
||||||
|
topic.title = xxxxx_title
|
||||||
|
topic.should_not be_valid
|
||||||
|
end
|
||||||
|
|
||||||
|
# describe "with a name" do
|
||||||
|
# it "is not valid without a name" do
|
||||||
|
# subject.stub(:name => nil)
|
||||||
|
# subject.should_not be_valid
|
||||||
|
# subject.should have(1).error_on(:name)
|
||||||
|
# subject.errors[:name].first.should == "can't be blank"
|
||||||
|
# end
|
||||||
|
# end
|
||||||
|
end
|
||||||
|
|
|
@ -26,30 +26,6 @@ describe Topic do
|
||||||
|
|
||||||
it_behaves_like "a versioned model"
|
it_behaves_like "a versioned model"
|
||||||
|
|
||||||
context '.title_quality' do
|
|
||||||
|
|
||||||
it "strips a title when identifying length" do
|
|
||||||
Fabricate.build(:topic, title: (" " * SiteSetting.min_topic_title_length) + "x").should_not be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
it "doesn't allow a long title" do
|
|
||||||
Fabricate.build(:topic, title: "x" * (SiteSetting.max_topic_title_length + 1)).should_not be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
it "doesn't allow a short title" do
|
|
||||||
Fabricate.build(:topic, title: "x" * (SiteSetting.min_topic_title_length + 1)).should_not be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
it "allows a regular title with a few ascii characters" do
|
|
||||||
Fabricate.build(:topic, title: "hello this is my cool topic! welcome: all;").should be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
it "allows non ascii" do
|
|
||||||
Fabricate.build(:topic, title: "Iñtërnâtiônàlizætiøn").should be_valid
|
|
||||||
end
|
|
||||||
|
|
||||||
end
|
|
||||||
|
|
||||||
context 'slug' do
|
context 'slug' do
|
||||||
|
|
||||||
let(:title) { "hello world topic" }
|
let(:title) { "hello world topic" }
|
||||||
|
@ -102,7 +78,7 @@ describe Topic do
|
||||||
SiteSetting.expects(:allow_duplicate_topic_titles?).returns(true)
|
SiteSetting.expects(:allow_duplicate_topic_titles?).returns(true)
|
||||||
end
|
end
|
||||||
|
|
||||||
it "won't allow another topic to be created with the same name" do
|
it "will allow another topic to be created with the same name" do
|
||||||
new_topic.should be_valid
|
new_topic.should be_valid
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
@ -112,10 +88,7 @@ describe Topic do
|
||||||
context 'html in title' do
|
context 'html in title' do
|
||||||
|
|
||||||
def build_topic_with_title(title)
|
def build_topic_with_title(title)
|
||||||
t = build(:topic, title: title)
|
build(:topic, title: title).tap{ |t| t.valid? }
|
||||||
t.sanitize_title
|
|
||||||
t.title_quality
|
|
||||||
t
|
|
||||||
end
|
end
|
||||||
|
|
||||||
let(:topic_bold) { build_topic_with_title("Topic with <b>bold</b> text in its title" ) }
|
let(:topic_bold) { build_topic_with_title("Topic with <b>bold</b> text in its title" ) }
|
||||||
|
|
Loading…
Reference in New Issue