journalduhacker/app/models/story.rb
2013-12-30 17:40:52 -05:00

418 lines
10 KiB
Ruby

class Story < ActiveRecord::Base
belongs_to :user
has_many :taggings,
:autosave => true
has_many :comments
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
attr_accessor :_comment_count
attr_accessor :vote, :already_posted_story, :fetched_content, :previewing
attr_accessor :editor_user_id, :moderation_reason
attr_accessible :title, :description, :tags_a, :moderation_reason
before_validation :assign_short_id,
:on => :create
before_save :log_moderation
after_create :mark_submitter
define_index do
indexes url
indexes title
indexes description
indexes user.username, :as => :author
indexes tags(:tag), :as => :tags
has created_at, :sortable => true
has hotness, is_expired
has "(cast(upvotes as signed) - cast(downvotes as signed))",
:as => :score, :type => :bigint, :sortable => true
set_property :field_weights => {
:upvotes => 15,
:title => 10,
:tags => 5,
}
where "is_expired = 0"
end
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_recent_similar_by_url(self.url))
errors.add(:url, "has already been submitted recently")
self.already_posted_story = s
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_recent_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 = [ "created_at >= ? AND (", (Time.now - 30.days) ]
urls.uniq.each_with_index do |u,x|
conds[0] << (x == 0 ? "" : " OR ") << "url = ?"
conds.push u
end
conds[0] << ")"
if s = Story.where(*conds).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] = comment_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
self.short_id = ShortId.new(self.class).generate
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 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 mark_submitter
Keystore.increment_value_for("user:#{self.user_id}:stories_submitted")
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}"
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 short_id_url
Rails.application.routes.url_helpers.root_url + "s/#{self.short_id}"
end
def comment_count
@_comment_count ||=
Keystore.value_for("story:#{self.id}:comment_count").to_i
end
def domain
if self.url.blank?
nil
else
pu = URI.parse(self.url)
pu.host.gsub(/^www\d*\./, "")
end
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 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 fetch_story_cache!
if self.url.present?
self.story_cache = StoryCacher.get_story_text(self.url)
end
end
def calculated_hotness
# don't immediately kill stories at 0 by bumping up score by one
order = Math.log([ (score + 1).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 * 48
return -((order * sign) + (self.created_at.to_f / window)).round(7)
end
def score
upvotes - downvotes
end
def vote_summary
r_counts = {}
Vote.where(:story_id => self.id, :comment_id => nil).each do |v|
r_counts[v.reason.to_s] ||= 0
r_counts[v.reason.to_s] += v.vote
end
r_counts.keys.sort.map{|k|
k == "" ? "+#{r_counts[k]}" : "#{r_counts[k]} #{Vote::STORY_REASONS[k]}"
}.join(", ")
end
def generated_markeddown_description
Markdowner.to_html(self.description, { :allow_images => true })
end
def description=(desc)
self[:description] = desc.to_s.rstrip
self.markeddown_description = self.generated_markeddown_description
end
def mailing_list_message_id
"story.#{short_id}.#{created_at.to_i}@#{Rails.application.domain}"
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.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 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
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 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_or_comments_url
self.url.blank? ? self.comments_url : self.url
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 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_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 can_be_seen_by_user?(user)
if is_gone? && !(user && (user.is_moderator? || user.id == self.user_id))
return false
end
true
end
def is_gone?
is_expired?
end
def recalculate_hotness!
update_column :hotness, calculated_hotness
end
def update_comment_count!
# calculate count after removing deleted comments and threads
alive_count = Comment.ordered_for_story_or_thread_for_user(self.id, nil,
nil).select{|c| !c.is_gone? }.count
Keystore.put("story:#{self.id}:comment_count", alive_count)
end
end