2012-06-17 03:15:46 +02:00
|
|
|
class Story < ActiveRecord::Base
|
2012-07-01 00:41:00 +02:00
|
|
|
belongs_to :user
|
|
|
|
has_many :taggings
|
2012-06-17 03:15:46 +02:00
|
|
|
has_many :comments
|
|
|
|
has_many :tags, :through => :taggings
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
validates_length_of :title, :in => 3..150
|
|
|
|
validates_length_of :description, :maximum => (64 * 1024)
|
|
|
|
validates_presence_of :user_id
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
# after this many minutes old, a story cannot be edited
|
|
|
|
MAX_EDIT_MINS = 30
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-10 21:44:26 +02:00
|
|
|
attr_accessor :_comment_count
|
2012-07-12 01:28:27 +02:00
|
|
|
attr_accessor :vote, :already_posted_story, :fetched_content, :previewing
|
2012-11-12 18:02:18 +01:00
|
|
|
attr_accessor :new_tag_names, :tags_to_add, :tags_to_delete
|
2012-09-02 16:50:07 +02:00
|
|
|
attr_accessor :editor_user_id, :moderation_reason
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-09-02 16:50:07 +02:00
|
|
|
attr_accessible :title, :description, :tags_a, :moderation_reason
|
2012-07-10 21:44:26 +02:00
|
|
|
|
2012-06-17 03:15:46 +02:00
|
|
|
before_create :assign_short_id
|
2012-11-12 18:02:18 +01:00
|
|
|
before_save :log_moderation
|
2012-07-01 20:38:01 +02:00
|
|
|
after_create :mark_submitter
|
2012-11-12 18:02:18 +01:00
|
|
|
after_save :add_or_delete_tags
|
2012-07-12 00:20:43 +02:00
|
|
|
|
|
|
|
define_index do
|
|
|
|
indexes url
|
|
|
|
indexes title
|
|
|
|
indexes description
|
|
|
|
indexes user.username, :as => :author
|
|
|
|
indexes tags(:tag), :as => :tags
|
|
|
|
|
|
|
|
has created_at, :sortable => true
|
2012-09-02 16:50:07 +02:00
|
|
|
has hotness, is_expired
|
2012-07-12 00:20:43 +02:00
|
|
|
has "(upvotes - downvotes)", :as => :score, :type => :integer,
|
|
|
|
:sortable => true
|
|
|
|
|
|
|
|
set_property :field_weights => {
|
|
|
|
:title => 10,
|
|
|
|
:tags => 5,
|
|
|
|
}
|
2012-07-12 20:30:20 +02:00
|
|
|
|
2012-09-02 16:50:07 +02:00
|
|
|
where "is_expired = 0"
|
2012-07-12 00:20:43 +02:00
|
|
|
end
|
2012-06-30 21:14:35 +02:00
|
|
|
|
|
|
|
validate do
|
|
|
|
if self.url.present?
|
|
|
|
# URI.parse is not very lenient, so we can't use it
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
if self.url.match(/\Ahttps?:\/\/([^\.]+\.)+[a-z]+(\/|\z)/)
|
2012-07-11 21:22:26 +02:00
|
|
|
if self.new_record? && (s = Story.find_recent_similar_by_url(self.url))
|
2012-06-30 21:14:35 +02:00
|
|
|
errors.add(:url, "has already been submitted recently")
|
|
|
|
self.already_posted_story = s
|
|
|
|
end
|
|
|
|
else
|
|
|
|
errors.add(:url, "is not valid")
|
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
elsif self.description.to_s.strip == ""
|
2012-07-01 01:00:05 +02:00
|
|
|
errors.add(:description, "must contain text if no URL posted")
|
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
check_tags
|
2012-06-30 21:14:35 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-11 21:22:26 +02:00
|
|
|
def self.find_recent_similar_by_url(url)
|
2012-07-18 20:15:48 +02:00
|
|
|
urls = [ url.to_s ]
|
|
|
|
urls2 = [ url.to_s ]
|
2012-07-18 01:17:46 +02:00
|
|
|
|
|
|
|
# https
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/^http:\/\//i, "https://")
|
|
|
|
urls2.push u.gsub(/^https:\/\//i, "http://")
|
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
|
|
|
# trailing slash
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/\/+\z/, "")
|
2012-07-18 20:15:48 +02:00
|
|
|
urls2.push (u + "/")
|
2012-07-18 01:17:46 +02:00
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
|
|
|
# www prefix
|
|
|
|
urls.each do |u|
|
|
|
|
urls2.push u.gsub(/^(https?:\/\/)www\d*\./i) {|_| $1 }
|
|
|
|
urls2.push u.gsub(/^(https?:\/\/)/i) {|_| "#{$1}www." }
|
|
|
|
end
|
|
|
|
urls = urls2.clone
|
|
|
|
|
|
|
|
conds = [ "created_at >= ? AND (", (Time.now - 30.days) ]
|
2012-07-18 20:15:48 +02:00
|
|
|
urls.uniq.each_with_index do |u,x|
|
2012-07-18 01:17:46 +02:00
|
|
|
conds[0] << (x == 0 ? "" : " OR ") << "url = ?"
|
2012-07-18 20:15:48 +02:00
|
|
|
conds.push u
|
2012-07-18 01:17:46 +02:00
|
|
|
end
|
|
|
|
conds[0] << ")"
|
|
|
|
|
|
|
|
if s = Story.find(:first, :conditions => conds)
|
|
|
|
return s
|
2012-07-11 21:22:26 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2012-10-09 18:06:43 +02:00
|
|
|
def self.recalculate_all_hotnesses!
|
|
|
|
Story.all.each do |s|
|
|
|
|
s.recalculate_hotness!
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def assign_short_id
|
2012-07-04 03:48:01 +02:00
|
|
|
10.times do |try|
|
|
|
|
if try == 10
|
2012-07-01 00:41:00 +02:00
|
|
|
raise "too many hash collisions"
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-07-12 22:51:57 +02:00
|
|
|
self.short_id = Utils.random_str(6).downcase
|
2012-07-04 03:48:01 +02:00
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
if !Story.find_by_short_id(self.short_id)
|
|
|
|
break
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-09-02 16:50:07 +02:00
|
|
|
def log_moderation
|
|
|
|
if self.new_record? || self.editor_user_id == self.user_id
|
|
|
|
return
|
|
|
|
end
|
|
|
|
|
|
|
|
m = Moderation.new
|
|
|
|
m.moderator_user_id = self.editor_user_id
|
|
|
|
m.story_id = self.id
|
|
|
|
|
|
|
|
if self.changes["is_expired"] && self.is_expired?
|
|
|
|
m.action = "deleted story"
|
|
|
|
elsif self.changes["is_expired"] && !self.is_expired?
|
|
|
|
m.action = "undeleted story"
|
|
|
|
else
|
|
|
|
actions = self.changes.map{|k,v| "changed #{k} from #{v[0].inspect} " <<
|
|
|
|
"to #{v[1].inspect}" }
|
|
|
|
|
|
|
|
if (old_tags = self.tags.map{|t| t.tag }) != self.tags_a
|
|
|
|
actions.push "changed tags from \"#{old_tags.join(", ")}\" to " <<
|
|
|
|
"\"#{self.tags_a.join(", ")}\""
|
|
|
|
end
|
|
|
|
|
|
|
|
m.action = actions.join(", ")
|
|
|
|
end
|
|
|
|
|
|
|
|
m.reason = self.moderation_reason
|
|
|
|
m.save
|
|
|
|
|
|
|
|
self.is_moderated = true
|
|
|
|
end
|
|
|
|
|
2012-07-03 21:29:00 +02:00
|
|
|
def give_upvote_or_downvote_and_recalculate_hotness!(upvote, downvote)
|
|
|
|
self.upvotes += upvote.to_i
|
|
|
|
self.downvotes += downvote.to_i
|
|
|
|
|
|
|
|
Story.connection.execute("UPDATE #{Story.table_name} SET " <<
|
|
|
|
"upvotes = COALESCE(upvotes, 0) + #{upvote.to_i}, " <<
|
|
|
|
"downvotes = COALESCE(downvotes, 0) + #{downvote.to_i}, " <<
|
|
|
|
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
|
|
|
|
end
|
|
|
|
|
2012-07-01 20:38:01 +02:00
|
|
|
def mark_submitter
|
|
|
|
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
|
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
# this has to happen just before save rather than in tags_a= because we need
|
|
|
|
# to have a valid user_id
|
2012-09-20 04:13:20 +02:00
|
|
|
def check_tags
|
|
|
|
(self.tags_to_add || []).each do |t|
|
|
|
|
if !t.valid_for?(self.user)
|
2012-09-20 17:53:11 +02:00
|
|
|
raise "#{self.user.username} does not have permission to use " <<
|
|
|
|
"privileged tag #{t.tag}"
|
2012-09-20 04:13:20 +02:00
|
|
|
end
|
|
|
|
end
|
2012-11-12 18:02:18 +01:00
|
|
|
|
|
|
|
if !(self.tags_to_add || []).reject{|t| t.is_media? }.any?
|
|
|
|
errors.add(:base, "Must have at least one non-media (PDF, video) tag. " <<
|
|
|
|
"If no tags apply to your content, it probably doesn't belong here.")
|
|
|
|
end
|
2012-09-20 04:13:20 +02:00
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
def add_or_delete_tags
|
2012-06-30 18:18:36 +02:00
|
|
|
(self.tags_to_delete || []).each do |t|
|
2012-06-17 03:15:46 +02:00
|
|
|
if t.is_a?(Tagging)
|
|
|
|
t.destroy
|
|
|
|
elsif t.is_a?(Tag)
|
|
|
|
self.taggings.find_by_tag_id(t.id).try(:destroy)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
(self.tags_to_add || []).each do |t|
|
2012-09-20 02:57:45 +02:00
|
|
|
if t.is_a?(Tag) && t.valid_for?(self.user)
|
2012-06-17 03:15:46 +02:00
|
|
|
tg = Tagging.new
|
|
|
|
tg.tag_id = t.id
|
|
|
|
tg.story_id = self.id
|
|
|
|
tg.save
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
self.tags_to_delete = []
|
|
|
|
self.tags_to_add = []
|
|
|
|
end
|
|
|
|
|
2012-07-03 18:59:50 +02:00
|
|
|
def comments_url
|
|
|
|
"#{short_id_url}/#{self.title_as_url}"
|
2012-07-02 19:13:12 +02:00
|
|
|
end
|
|
|
|
|
2012-07-03 18:59:50 +02:00
|
|
|
def short_id_url
|
|
|
|
Rails.application.routes.url_helpers.root_url + "s/#{self.short_id}"
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def comment_count
|
|
|
|
@_comment_count ||=
|
2012-06-17 03:15:46 +02:00
|
|
|
Keystore.value_for("story:#{self.id}:comment_count").to_i
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
def domain
|
|
|
|
if self.url.blank?
|
|
|
|
nil
|
|
|
|
else
|
|
|
|
pu = URI.parse(self.url)
|
|
|
|
pu.host.gsub(/^www\d*\./, "")
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def fetched_title(for_remote_ip = nil)
|
|
|
|
doc = Nokogiri::HTML(fetched_content(for_remote_ip).to_s)
|
2012-07-02 03:47:25 +02:00
|
|
|
if doc
|
|
|
|
return doc.at_css("title").try(:text)
|
|
|
|
else
|
|
|
|
return ""
|
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
|
|
|
|
|
|
|
def fetched_content(for_remote_ip = nil)
|
|
|
|
return @fetched_content if @fetched_content
|
|
|
|
|
|
|
|
begin
|
|
|
|
s = Sponge.new
|
|
|
|
s.timeout = 3
|
|
|
|
@fetched_content = s.fetch(self.url, :get, nil, nil,
|
|
|
|
{ "User-agent" => "lobste.rs! for #{for_remote_ip}" }, 3)
|
|
|
|
rescue
|
|
|
|
end
|
|
|
|
|
|
|
|
@fetched_content
|
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-03 21:29:00 +02:00
|
|
|
def calculated_hotness
|
2012-06-17 03:15:46 +02:00
|
|
|
score = upvotes - downvotes
|
2012-10-09 18:06:43 +02:00
|
|
|
|
2012-06-17 03:15:46 +02:00
|
|
|
order = Math.log([ score.abs, 1 ].max, 10)
|
|
|
|
if score > 0
|
|
|
|
sign = 1
|
|
|
|
elsif score < 0
|
|
|
|
sign = -1
|
|
|
|
else
|
|
|
|
sign = 0
|
|
|
|
end
|
|
|
|
|
2012-09-20 17:59:30 +02:00
|
|
|
# TODO: as the site grows, shrink this down to 12 or so.
|
2012-10-09 18:06:43 +02:00
|
|
|
window = 60 * 60 * 48
|
2012-07-10 18:53:05 +02:00
|
|
|
|
2012-10-08 17:04:02 +02:00
|
|
|
return -(order + (sign * (self.created_at.to_f / window))).round(7)
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-07-05 17:08:14 +02:00
|
|
|
def generated_markeddown_description
|
2012-09-17 20:24:29 +02:00
|
|
|
Markdowner.to_html(self.description, allow_images = true)
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-07-05 17:08:14 +02:00
|
|
|
def description=(desc)
|
2012-11-13 18:39:50 +01:00
|
|
|
# TODO: remove remove_mb4 hack
|
|
|
|
self[:description] = desc.to_s.remove_mb4.rstrip
|
2012-07-05 17:08:14 +02:00
|
|
|
self.markeddown_description = self.generated_markeddown_description
|
|
|
|
end
|
|
|
|
|
2012-07-12 01:28:27 +02:00
|
|
|
@_tags_a = []
|
2012-06-17 03:15:46 +02:00
|
|
|
def tags_a
|
2012-07-12 01:28:27 +02:00
|
|
|
@_tags_a ||= tags.map{|t| t.tag }
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
def tags_a=(new_tag_names)
|
2012-06-17 03:15:46 +02:00
|
|
|
self.tags_to_delete = []
|
|
|
|
self.tags_to_add = []
|
2012-11-12 18:02:18 +01:00
|
|
|
self.new_tag_names = new_tag_names.reject{|t| t.blank? }
|
2012-06-17 03:15:46 +02:00
|
|
|
|
|
|
|
self.tags.each do |tag|
|
2012-11-12 18:02:18 +01:00
|
|
|
if !new_tag_names.include?(tag.tag)
|
2012-06-17 03:15:46 +02:00
|
|
|
self.tags_to_delete.push tag
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
new_tag_names.each do |tag|
|
2012-06-17 03:15:46 +02:00
|
|
|
if tag.to_s != "" && !self.tags.map{|t| t.tag }.include?(tag)
|
|
|
|
if t = Tag.find_by_tag(tag)
|
|
|
|
self.tags_to_add.push t
|
|
|
|
end
|
|
|
|
end
|
|
|
|
end
|
2012-07-12 01:28:27 +02:00
|
|
|
|
2012-11-12 18:02:18 +01:00
|
|
|
@_tags_a = self.new_tag_names
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
|
2012-07-11 21:22:26 +02:00
|
|
|
def url=(u)
|
|
|
|
# strip out stupid google analytics parameters
|
|
|
|
if u && (m = u.match(/\A([^\?]+)\?(.+)\z/))
|
|
|
|
params = m[2].split("&")
|
|
|
|
params.reject!{|p|
|
|
|
|
p.match(/^utm_(source|medium|campaign|term|content)=/) }
|
|
|
|
|
|
|
|
u = m[1] << (params.any?? "?" << params.join("&") : "")
|
|
|
|
end
|
|
|
|
|
|
|
|
self[:url] = u
|
|
|
|
end
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
def title=(t)
|
|
|
|
# change unicode whitespace characters into real spaces
|
2012-11-08 04:58:10 +01:00
|
|
|
# TODO: remove remove_mb4 hack
|
|
|
|
self[:title] = t.strip.remove_mb4
|
2012-06-30 18:18:36 +02:00
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def title_as_url
|
|
|
|
u = self.title.downcase.gsub(/[^a-z0-9_-]/, "_")
|
|
|
|
while u.match(/__/)
|
|
|
|
u.gsub!("__", "_")
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
2012-07-02 18:12:24 +02:00
|
|
|
u.gsub(/^_+/, "").gsub(/_+$/, "")
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-03 18:59:50 +02:00
|
|
|
def url_or_comments_url
|
|
|
|
self.url.blank? ? self.comments_url : self.url
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def is_editable_by_user?(user)
|
2012-09-02 16:50:07 +02:00
|
|
|
if user && user.is_moderator?
|
2012-07-05 02:33:12 +02:00
|
|
|
return true
|
2012-07-04 16:06:44 +02:00
|
|
|
elsif user && user.id == self.user_id
|
2012-07-05 02:33:12 +02:00
|
|
|
if self.is_moderated?
|
|
|
|
return false
|
|
|
|
else
|
|
|
|
return (Time.now.to_i - self.created_at.to_i < (60 * MAX_EDIT_MINS))
|
|
|
|
end
|
2012-07-04 16:06:44 +02:00
|
|
|
else
|
2012-06-17 03:15:46 +02:00
|
|
|
return false
|
|
|
|
end
|
2012-07-01 00:41:00 +02:00
|
|
|
end
|
|
|
|
|
2012-06-30 18:18:36 +02:00
|
|
|
def is_undeletable_by_user?(user)
|
2012-09-02 16:50:07 +02:00
|
|
|
if user && user.is_moderator?
|
2012-07-12 20:30:20 +02:00
|
|
|
return true
|
|
|
|
elsif user && user.id == self.user_id && !self.is_moderated?
|
2012-07-05 02:33:12 +02:00
|
|
|
return true
|
|
|
|
else
|
|
|
|
return false
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
|
|
|
def can_be_seen_by_user?(user)
|
2012-09-02 16:50:07 +02:00
|
|
|
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
|
2012-06-30 18:18:36 +02:00
|
|
|
return false
|
|
|
|
end
|
|
|
|
|
|
|
|
true
|
|
|
|
end
|
2012-06-17 03:15:46 +02:00
|
|
|
|
2012-07-05 02:33:12 +02:00
|
|
|
def is_gone?
|
2012-09-02 16:50:07 +02:00
|
|
|
is_expired?
|
2012-07-05 02:33:12 +02:00
|
|
|
end
|
|
|
|
|
2012-10-09 18:06:43 +02:00
|
|
|
def recalculate_hotness!
|
|
|
|
Story.connection.execute("UPDATE #{Story.table_name} SET " <<
|
|
|
|
"hotness = '#{self.calculated_hotness}' WHERE id = #{self.id.to_i}")
|
|
|
|
end
|
|
|
|
|
2012-07-01 00:41:00 +02:00
|
|
|
def update_comment_count!
|
|
|
|
Keystore.put("story:#{self.id}:comment_count",
|
2012-10-24 18:52:02 +02:00
|
|
|
Comment.where(:story_id => self.id, :is_deleted => 0,
|
|
|
|
:is_moderated => 0).count)
|
2012-06-17 03:15:46 +02:00
|
|
|
end
|
|
|
|
end
|