9535b05490
stories should either be reported for spam (coming later), upvoted, or left alone rather than being downvoted for being uninteresting. since users don't like leaving uninteresting things alone, they can now hide stories from their view without affecting the story's score. hiding is implemented as a Vote with its vote set to 0 and the reason set to "H" add a /hidden url which shows all of a user's hidden stories while i'm here, simplify Vote guts and add some tests to make sure all the flip-flopping stuff works right
399 lines
9.6 KiB
Ruby
399 lines
9.6 KiB
Ruby
class Story < ActiveRecord::Base
|
|
belongs_to :user
|
|
has_many :taggings,
|
|
:autosave => true
|
|
has_many :comments,
|
|
:inverse_of => :story
|
|
has_many :tags, :through => :taggings
|
|
|
|
validates_length_of :title, :in => 3..150
|
|
validates_length_of :description, :maximum => (64 * 1024)
|
|
validates_presence_of :user_id
|
|
|
|
# after this many minutes old, a story cannot be edited
|
|
MAX_EDIT_MINS = 30
|
|
|
|
# days a story is considered recent, for resubmitting
|
|
RECENT_DAYS = 30
|
|
|
|
attr_accessor :vote, :already_posted_story, :fetched_content, :previewing,
|
|
:seen_previous
|
|
attr_accessor :editor_user_id, :moderation_reason
|
|
|
|
before_validation :assign_short_id_and_upvote,
|
|
:on => :create
|
|
before_save :log_moderation
|
|
after_create :mark_submitter, :record_initial_upvote
|
|
|
|
validate do
|
|
if self.url.present?
|
|
# URI.parse is not very lenient, so we can't use it
|
|
|
|
if self.url.match(/\Ahttps?:\/\/([^\.]+\.)+[a-z]+(\/|\z)/)
|
|
if self.new_record? && (s = Story.find_similar_by_url(self.url))
|
|
self.already_posted_story = s
|
|
if s.is_recent?
|
|
errors.add(:url, "has already been submitted within the past " <<
|
|
"#{RECENT_DAYS} days")
|
|
end
|
|
end
|
|
else
|
|
errors.add(:url, "is not valid")
|
|
end
|
|
elsif self.description.to_s.strip == ""
|
|
errors.add(:description, "must contain text if no URL posted")
|
|
end
|
|
|
|
check_tags
|
|
end
|
|
|
|
def self.find_similar_by_url(url)
|
|
urls = [ url.to_s ]
|
|
urls2 = [ url.to_s ]
|
|
|
|
# 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/, "")
|
|
urls2.push (u + "/")
|
|
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 = [ "" ]
|
|
urls.uniq.each_with_index do |u,x|
|
|
conds[0] << (x == 0 ? "" : " OR ") << "url = ?"
|
|
conds.push u
|
|
end
|
|
|
|
if s = Story.where(*conds).order("id DESC").first
|
|
return s
|
|
end
|
|
|
|
false
|
|
end
|
|
|
|
def self.recalculate_all_hotnesses!
|
|
Story.all.each do |s|
|
|
s.recalculate_hotness!
|
|
end
|
|
end
|
|
|
|
def as_json(options = {})
|
|
h = super(:only => [
|
|
:short_id,
|
|
:created_at,
|
|
:title,
|
|
:url,
|
|
])
|
|
h[:score] = score
|
|
h[:comment_count] = comments_count
|
|
h[:description] = markeddown_description
|
|
h[:comments_url] = comments_url
|
|
h[:submitter_user] = user
|
|
|
|
if options && options[:with_comments]
|
|
h[:comments] = options[:with_comments]
|
|
end
|
|
|
|
h
|
|
end
|
|
|
|
def assign_short_id_and_upvote
|
|
self.short_id = ShortId.new(self.class).generate
|
|
self.upvotes = 1
|
|
end
|
|
|
|
def calculated_hotness
|
|
order = Math.log([ score.abs, 1 ].max, 10)
|
|
if score > 0
|
|
sign = 1
|
|
elsif score < 0
|
|
sign = -1
|
|
else
|
|
sign = 0
|
|
end
|
|
|
|
# TODO: as the site grows, shrink this down to 12 or so.
|
|
window = 60 * 60 * 36
|
|
|
|
return -((order * sign) + (self.created_at.to_f / window)).round(7)
|
|
end
|
|
|
|
def can_be_seen_by_user?(user)
|
|
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
|
|
return false
|
|
end
|
|
|
|
true
|
|
end
|
|
|
|
# this has to happen just before save rather than in tags_a= because we need
|
|
# to have a valid user_id
|
|
def check_tags
|
|
self.taggings.each do |t|
|
|
if !t.tag.valid_for?(self.user)
|
|
raise "#{self.user.username} does not have permission to use " <<
|
|
"privileged tag #{t.tag.tag}"
|
|
elsif t.tag.inactive? && !t.new_record?
|
|
# stories can have inactive tags as long as they existed before
|
|
raise "#{self.user.username} cannot add inactive tag #{t.tag.tag}"
|
|
end
|
|
end
|
|
|
|
if !self.taggings.reject{|t| t.marked_for_destruction? || t.tag.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
|
|
end
|
|
|
|
def comments_url
|
|
"#{short_id_url}/#{self.title_as_url}"
|
|
end
|
|
|
|
def description=(desc)
|
|
self[:description] = desc.to_s.rstrip
|
|
self.markeddown_description = self.generated_markeddown_description
|
|
end
|
|
|
|
def domain
|
|
if self.url.blank?
|
|
nil
|
|
else
|
|
pu = URI.parse(self.url)
|
|
pu.host.gsub(/^www\d*\./, "")
|
|
end
|
|
end
|
|
|
|
def fetch_story_cache!
|
|
if self.url.present?
|
|
self.story_cache = StoryCacher.get_story_text(self.url)
|
|
end
|
|
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" => "#{Rails.application.domain} for #{for_remote_ip}" },
|
|
3)
|
|
rescue
|
|
end
|
|
|
|
@fetched_content
|
|
end
|
|
|
|
def fetched_title(for_remote_ip = nil)
|
|
doc = Nokogiri::HTML(fetched_content(for_remote_ip).to_s)
|
|
if doc
|
|
return doc.at_css("title").try(:text)
|
|
else
|
|
return ""
|
|
end
|
|
end
|
|
|
|
def generated_markeddown_description
|
|
Markdowner.to_html(self.description, { :allow_images => true })
|
|
end
|
|
|
|
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
|
|
|
|
def is_editable_by_user?(user)
|
|
if user && user.is_moderator?
|
|
return true
|
|
elsif user && user.id == self.user_id
|
|
if self.is_moderated?
|
|
return false
|
|
else
|
|
return (Time.now.to_i - self.created_at.to_i < (60 * MAX_EDIT_MINS))
|
|
end
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
|
|
def is_gone?
|
|
is_expired?
|
|
end
|
|
|
|
def is_recent?
|
|
self.created_at >= RECENT_DAYS.days.ago
|
|
end
|
|
|
|
def is_undeletable_by_user?(user)
|
|
if user && user.is_moderator?
|
|
return true
|
|
elsif user && user.id == self.user_id && !self.is_moderated?
|
|
return true
|
|
else
|
|
return false
|
|
end
|
|
end
|
|
|
|
def log_moderation
|
|
if self.new_record? || !self.editor_user_id ||
|
|
self.editor_user_id == self.user_id
|
|
return
|
|
end
|
|
|
|
all_changes = self.changes.merge(self.tagging_changes)
|
|
|
|
m = Moderation.new
|
|
m.moderator_user_id = self.editor_user_id
|
|
m.story_id = self.id
|
|
|
|
if all_changes["is_expired"] && self.is_expired?
|
|
m.action = "deleted story"
|
|
elsif all_changes["is_expired"] && !self.is_expired?
|
|
m.action = "undeleted story"
|
|
else
|
|
m.action = all_changes.map{|k,v| "changed #{k} from #{v[0].inspect} " <<
|
|
"to #{v[1].inspect}" }.join(", ")
|
|
end
|
|
|
|
m.reason = self.moderation_reason
|
|
m.save
|
|
|
|
self.is_moderated = true
|
|
end
|
|
|
|
def mailing_list_message_id
|
|
"story.#{short_id}.#{created_at.to_i}@#{Rails.application.domain}"
|
|
end
|
|
|
|
def mark_submitter
|
|
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
|
|
end
|
|
|
|
def recalculate_hotness!
|
|
update_column :hotness, calculated_hotness
|
|
end
|
|
|
|
def record_initial_upvote
|
|
Vote.vote_thusly_on_story_or_comment_for_user_because(1, self.id, nil,
|
|
self.user_id, nil, false)
|
|
end
|
|
|
|
def short_id_url
|
|
Rails.application.routes.url_helpers.root_url + "s/#{self.short_id}"
|
|
end
|
|
|
|
def score
|
|
upvotes - downvotes
|
|
end
|
|
|
|
def tagging_changes
|
|
old_tags_a = self.taggings.reject{|tg| tg.new_record? }.map{|tg|
|
|
tg.tag.tag }.join(" ")
|
|
new_tags_a = self.taggings.reject{|tg| tg.marked_for_destruction?
|
|
}.map{|tg| tg.tag.tag }.join(" ")
|
|
|
|
if old_tags_a == new_tags_a
|
|
{}
|
|
else
|
|
{ "tags" => [ old_tags_a, new_tags_a ] }
|
|
end
|
|
end
|
|
|
|
@_tags_a = []
|
|
def tags_a
|
|
@_tags_a ||= self.taggings.map{|t| t.tag.tag }
|
|
end
|
|
|
|
def tags_a=(new_tag_names_a)
|
|
self.taggings.each do |tagging|
|
|
if !new_tag_names_a.include?(tagging.tag.tag)
|
|
tagging.mark_for_destruction
|
|
end
|
|
end
|
|
|
|
new_tag_names_a.each do |tag_name|
|
|
if tag_name.to_s != "" && !self.tags.exists?(:tag => tag_name)
|
|
if t = Tag.active.where(:tag => tag_name).first
|
|
# we can't lookup whether the user is allowed to use this tag yet
|
|
# because we aren't assured to have a user_id by now; we'll do it in
|
|
# the validation with check_tags
|
|
tg = self.taggings.build
|
|
tg.tag_id = t.id
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
def title=(t)
|
|
# change unicode whitespace characters into real spaces
|
|
self[:title] = t.strip
|
|
end
|
|
|
|
def title_as_url
|
|
u = self.title.downcase.gsub(/[^a-z0-9_-]/, "_")
|
|
while u.match(/__/)
|
|
u.gsub!("__", "_")
|
|
end
|
|
u.gsub(/^_+/, "").gsub(/_+$/, "")
|
|
end
|
|
|
|
def to_param
|
|
self.short_id
|
|
end
|
|
|
|
def update_comments_count!
|
|
comments = self.comments.arrange_for_user(nil)
|
|
|
|
# calculate count after removing deleted comments and threads
|
|
self.update_column :comments_count, comments.count{|c| !c.is_gone? }
|
|
end
|
|
|
|
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.to_s.strip
|
|
end
|
|
|
|
def url_is_editable_by_user?(user)
|
|
if self.new_record?
|
|
true
|
|
elsif user && user.is_moderator? && self.url.present?
|
|
true
|
|
else
|
|
false
|
|
end
|
|
end
|
|
|
|
def url_or_comments_url
|
|
self.url.blank? ? self.comments_url : self.url
|
|
end
|
|
end
|