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