require 'digest/md5'
require 'time'
require 'wraptools'
# All posts in a topic are organized into a tree, stored as Nested Set.
# (see http://threebit.net/tutorials/nestedset/tutorial1.html)
#
# The l and r attributes are the nested set boundaries.
#
# Also, traditional tree-like structure of parent_id links is maintained, just in case
# the nested set should get corrupted.
# Nested Set is used because it provides faster reads at a price of slower updates.
class Post < ActiveRecord::Base
belongs_to :topic
belongs_to :user
belongs_to :parent, :class_name => 'Post', :foreign_key => 'parent_id'
has_many :children, :class_name => 'Post', :foreign_key => 'parent_id'
has_many :post_votes, :dependent => true
has_many :attachments, :dependent => true
composed_of :guest, :mapping => [ %w(guest_name guest_name), %w(guest_email guest_email) ]
# TODO: don't tokenize the id fields
acts_as_ferret :fields => [:subject, :text, :user_id, :forum_id, :deleted], :auto_index_update => false
attr_accessor :new_attachment
validates_length_of :text, :within => 2..50000,
:too_short => 'formerror_text_short', :too_long => 'formerror_text_long'
validates_length_of :subject, :within => 3..60,
:too_short => 'formerror_subject_short', :too_long => 'formerror_subject_long'
validates_uniqueness_of :messageid
include ErrorRaising
# CLASS METHODS
# Find a post and all its parents.
def self.find_with_all_parents(id)
find_by_sql <<-EOL
SELECT p.* FROM posts p, posts c
WHERE c.l BETWEEN p.l AND p.r
AND c.id = #{id.to_i}
AND p.topic_id = c.topic_id
ORDER BY p.l
EOL
end
# Find the number latest posts.
def self.find_latest(number)
Post.find_all "deleted = 0", 'created_at DESC, id DESC', number
end
# Finds post with specified id, and adds data from corresponding USERS row
# into it. Views commonly need post with this data, so this method saves
# a database roundtrip.
def self.find_with_user_data(id)
query = <<-EOL
SELECT posts.*,
users.name AS user_name,
users.firstname AS user_firstname,
users.surname AS user_surname
FROM posts
LEFT JOIN users ON posts.user_id = users.id
WHERE posts.id = #{id.to_i}
EOL
find_first_by_sql(query)
end
# Find all posts with references in a range of nntpids.
# TODO: move to forum.rb?
# FIXME: this method uses non-existant association 'post.references'
def self.find_in_nntp_range(forum_id, first, last)
query = "
SELECT posts.*,users.name AS user_name,
users.firstname AS user_firstname,
users.surname AS user_surname
FROM posts
LEFT JOIN topics ON posts.topic_id = topics.id
LEFT JOIN users ON posts.user_id = users.id
WHERE topics.forum_id = #{forum_id.to_i}
AND posts.nntpid BETWEEN #{first.to_i} AND #{last.to_i}
ORDER BY nntpid ASC
"
posts = find_by_sql(query)
query = "
SELECT parent.messageid as refid,
child.id AS id
FROM posts parent, posts child
LEFT JOIN topics ON child.topic_id = topics.id
WHERE child.nntpid BETWEEN #{first.to_i} AND #{last.to_i}
AND parent.topic_id = child.topic_id
AND child.l BETWEEN parent.l AND parent.r
AND parent.id <> child.id
AND topics.forum_id = #{forum_id.to_i}
ORDER BY parent.l
"
references = Hash.new
find_by_sql(query).each do |ref|
unless ref.refid == nil
references[ref.id] ||= Array.new
references[ref.id] << ref.refid
end
end
posts.each do |post|
if references[post.id]
post['references'] = references[post.id].inject('') { |refs, msgid| refs + "<#{msgid}> " }.strip
else
post['references'] = nil
end
end
posts
end
# Return an Array of posts matching the query string
def self.search(query, forums=[], number=1, offset=0)
if forums.size > 0
forum_query = " +forum_id:(#{forums.join('|')}"
else
forum_query = ""
end
self.find_by_contents(query + forum_query + ' +deleted:0', :first_doc => offset, :num_docs => number)
end
# CALLBACKS
def before_validation
# Guest post
if self.user_id.nil?
unless self.guest_name.nil?
self.guest_name.strip!
self.guest_name = 'Guest' if self.guest_name.size == 0
end
self.guest_email.strip! unless self.guest_email.nil?
else
self.guest_name = nil
self.guest_email = nil
end
self.text = Wraptools::wrap_ff(self.text, 72) unless self.text.nil?
self.subject.strip! unless self.subject.nil?
# limit to 60 chars
if self.subject && self.subject.size > 60
self.subject = self.subject[0, 60]
end
end
def validate
# spam filter (only for forum posts)
if self.post_method != 'mail'
matches = RForum::CONFIG[:spam_filter_regex].match(self.subject.to_s + self.text.to_s)
if matches
errors.add('text', "Your post seems to contain Spam: \"#{matches[0]}\".")
end
end
check_mandatory_atributes(:topic_id)
if self.parent_id and self.parent.topic_id != self.topic_id
raise ArgumentError.new("Post ##{self.parent_id} does not belong to topic #{self.topic_id}")
end
if @new_attachment && @new_attachment.size > RForum::CONFIG[:max_attachment_size]
errors.add('new_attachment', :attachment_too_large)
end
errors.add('text', :formerror_text_short) if text.nil?
errors.add('subject', :formerror_subject_short) if subject.nil?
# anoymous user may be required specify a name and email address
if user_id.nil? and not RForum::CONFIG[:anon_posting_allowed]
errors.add_on_empty %w(guest_name guest_email)
if (guest_name and guest_name.size < 2)
errors.add('guest_name', :formerror_no_guest_name)
end
if (guest_email and not valid_email?(guest_email))
errors.add('guest_email', :formerror_invalid_email)
end
end
errors.add('text', :formerror_too_many_quoted_lines) if too_many_quoted_lines?
end
def before_create
# Generate a Messageid if none is given
if self.messageid.nil? or self.messageid.to_s.strip.empty?
self.messageid = make_messageid
end
insert_into_nested_set
self.nntpid = self.topic.forum.get_next_free_nntpid
self.deleted = 0
end
def after_create
# send list mails
if RForum::CONFIG[:deliver_mail] and self.topic.forum.list_address and self.post_method != 'mail'
Mailer.deliver_ml_post(self)
end
# notify users
self.topic.topic_subscriptions.each do |subscription|
subscription.do_notify(self)
end
end
def before_destroy
transaction do
self.reload
# destroy all children
Post.destroy_all "topic_id = #{self.topic_id} AND parent_id = #{self.id}"
self.reload
move = 2 * (1 + ((self.r - self.l) / 2.0).floor)
Post.update_all "l = l - #{move}", "topic_id = #{self.topic_id} AND l > #{self.r}"
Post.update_all "r = r - #{move}", "topic_id = #{self.topic_id} AND r > #{self.r}"
end
end
def after_destroy
Topic.find(self.topic_id).destroy if self.root?
rescue ActiveRecord::RecordNotFound
end
def after_save
self.topic(:reload)
self.topic.update_last_post_data
self.topic.update_post_counter
self.topic.subject = self.subject if self.root?
self.topic.save
if @new_attachment
self.attach_file(@new_attachment.original_filename, @new_attachment.read)
end
end
# OTHER METHODS
# pseudo attribute for ferret indexer
def forum_id
self.topic.forum_id
end
# Add a reply to the current post, and return the saved reply
def add_reply(post)
# Transaction::Simple is not necessary here, because post won't be
# used anymore
transaction do
post.parent = self
post.topic = self.topic
post.save
end
return post
end
def attach_file(filename, data)
a = Attachment.new
a.filename = filename
a.data = data
a.post = self
a.save
a
end
def get_display_name
self.author.get_display_name or '(unknown)'
end
# Contructs the References header for a post by combining messageid's of all
# (direct or indirect) parents of this message
def get_references
# This may include hidden posts.
messageids = connection.select_all <<-EOL
SELECT parent.messageid as id
FROM posts parent, posts child
WHERE child.id = #{self.id}
AND parent.topic_id = child.topic_id
AND parent.id <> #{self.id}
AND child.l BETWEEN parent.l AND parent.r
ORDER BY parent.l
EOL
messageids.inject('') { |refs, msgid| refs + "<#{msgid['id']}> " }.strip
end
def has_undeleted_children?
(self.children.find_all("deleted = 0").size > 0)
end
def hide(recursive=false)
self.update_attribute('deleted', 1)
if recursive
self.children.each { |child| child.hide(:recursive) }
end
end
def unhide(recursive=false)
raise "Error: can't unhide post if topic is hidden" if self.topic.hidden?
self.update_attribute('deleted', 0)
if recursive
self.children.each { |child| child.unhide(:recursive) }
end
end
def hidden?
(self.deleted == 1)
end
def quoted_text
Wraptools::quote(self.text)
end
# Calculates a reply subject in format "Re: ",
# unless post's own subject already starts with Re:
def reply_subject
s = self.subject
if s =~ /[Rr]e:.*/
s
else
"Re: #{s.strip}"
end
end
# true if this post has no parents (i.e., it is a topic root)
def root?
self.parent_id.nil?
end
# TODO test me
def too_many_quoted_lines?
return false if self.text.nil?
lines = self.text.split(/\n/).size
unquoted_lines = (self.text.split(/\n/).delete_if {|l| l =~ /^>/ }).size
quoted_lines = lines - unquoted_lines
return (lines > 12 and lines / unquoted_lines > 6)
end
def author
self.user or self.guest
end
def author=(a)
if a.is_a? User # also true for Admin
self.user = a
elsif a.is_a? Guest
self.guest = a
else
raise ArgumentError
end
end
private
# Create a quazi-unique (random over a wide range of values, actualy) messageid for a post.
def make_messageid
Digest::MD5.hexdigest(
self.text.to_s +
self.subject.to_s +
self.user_id.to_s +
self.guest_name.to_s +
self.guest_email.to_s +
self.parent_id.to_s) + '@' + RForum::CONFIG[:hostname]
end
# Insert the post into the topic's nested set (which means re-calculating r and l indexes
# of other posts in the same topic
def insert_into_nested_set
if self.root?
self.l = 1
self.r = 2
else
the_parent = self.parent
self.topic_id = the_parent.topic_id
self.l = the_parent.r
self.r = the_parent.r + 1
# shift all elements that must be right of the new element
self.class.update_all('l = l + 2', "topic_id = #{self.topic_id} AND l >= #{self.r}")
self.class.update_all('r = r + 2', "topic_id = #{self.topic_id} AND r >= #{self.l}")
end
end
end