# Responsible for creating posts and topics # require_dependency 'rate_limiter' require_dependency 'topic_creator' require_dependency 'post_jobs_enqueuer' require_dependency 'distributed_mutex' class PostCreator attr_reader :errors, :opts # Acceptable options: # # raw - raw text of post # image_sizes - We can pass a list of the sizes of images in the post as a shortcut. # invalidate_oneboxes - Whether to force invalidation of oneboxes in this post # acting_user - The user performing the action might be different than the user # who is the post "author." For example when copying posts to a new # topic. # created_at - Post creation time (optional) # auto_track - Automatically track this topic if needed (default true) # custom_fields - Custom fields to be added to the post, Hash (default nil) # post_type - Whether this is a regular post or moderator post. # no_bump - Do not cause this post to bump the topic. # cooking_options - Options for rendering the text # cook_method - Method of cooking the post. # :regular - Pass through Markdown parser and strip bad HTML # :raw_html - Perform no processing # via_email - Mark this post as arriving via email # raw_email - Full text of arriving email (to store) # # When replying to a topic: # topic_id - topic we're replying to # reply_to_post_number - post number we're replying to # # When creating a topic: # title - New topic title # archetype - Topic archetype # is_warning - Is the topic a warning? # category - Category to assign to topic # target_usernames - comma delimited list of usernames for membership (private message) # target_group_names - comma delimited list of groups for membership (private message) # meta_data - Topic meta data hash # created_at - Topic creation time (optional) # pinned_at - Topic pinned time (optional) # pinned_globally - Is the topic pinned globally (optional) # def initialize(user, opts) # TODO: we should reload user in case it is tainted, should take in a user_id as opposed to user # If we don't do this we introduce a rather risky dependency @user = user @opts = opts || {} @spam = false end # True if the post was considered spam def spam? @spam end def skip_validations? @opts[:skip_validations] end def guardian @guardian ||= Guardian.new(@user) end def create @topic = nil @post = nil if @user.suspended? && !skip_validations? @errors = Post.new.errors @errors.add(:base, I18n.t(:user_is_suspended)) return end transaction do setup_topic setup_post rollback_if_host_spam_detected plugin_callbacks save_post extract_links store_unique_post_key track_topic update_topic_stats update_topic_auto_close update_user_counts create_embedded_topic ensure_in_allowed_users if guardian.is_staff? @post.advance_draft_sequence @post.save_reply_relationships end if @post && @post.errors.empty? publish PostAlerter.post_created(@post) unless @opts[:import_mode] || @opts[:skip_notifications] track_latest_on_category enqueue_jobs BadgeGranter.queue_badge_grant(Badge::Trigger::PostRevision, post: @post) end if @post || @spam handle_spam unless @opts[:import_mode] end @post end def self.create(user, opts) PostCreator.new(user, opts).create end def self.before_create_tasks(post) set_reply_user_id(post) post.word_count = post.raw.scan(/\w+/).size post.post_number ||= Topic.next_post_number(post.topic_id, post.reply_to_post_number.present?) cooking_options = post.cooking_options || {} cooking_options[:topic_id] = post.topic_id post.cooked ||= post.cook(post.raw, cooking_options) post.sort_order = post.post_number post.last_version_at ||= Time.now end def self.set_reply_user_id(post) return unless post.reply_to_post_number.present? post.reply_to_user_id ||= Post.select(:user_id).find_by(topic_id: post.topic_id, post_number: post.reply_to_post_number).try(:user_id) end protected def transaction(&blk) Post.transaction do if new_topic? blk.call else # we need to ensure post_number is monotonically increasing with no gaps # so we serialize creation to avoid needing rollbacks DistributedMutex.synchronize("topic_id_#{@opts[:topic_id]}", &blk) end end end # You can supply an `embed_url` for a post to set up the embedded relationship. # This is used by the wp-discourse plugin to associate a remote post with a # discourse post. def create_embedded_topic return unless @opts[:embed_url].present? TopicEmbed.create!(topic_id: @post.topic_id, post_id: @post.id, embed_url: @opts[:embed_url]) end def handle_spam if @spam GroupMessage.create( Group[:moderators].name, :spam_post_blocked, { user: @user, limit_once_per: 24.hours, message_params: {domains: @post.linked_hosts.keys.join(', ')} } ) elsif @post && !@post.errors.present? && !skip_validations? SpamRulesEnforcer.enforce!(@post) end end def plugin_callbacks DiscourseEvent.trigger :before_create_post, @post DiscourseEvent.trigger :validate_post, @post end def track_latest_on_category return unless @post && @post.errors.count == 0 && @topic && @topic.category_id Category.where(id: @topic.category_id).update_all(latest_post_id: @post.id) Category.where(id: @topic.category_id).update_all(latest_topic_id: @topic.id) if @post.post_number == 1 end def ensure_in_allowed_users return unless @topic.private_message? unless @topic.topic_allowed_users.where(user_id: @user.id).exists? @topic.topic_allowed_users.create!(user_id: @user.id) end end private def setup_topic if new_topic? topic_creator = TopicCreator.new(@user, guardian, @opts) begin topic = topic_creator.create @errors = topic_creator.errors rescue ActiveRecord::Rollback => ex # In the event of a rollback, grab the errors from the topic @errors = topic_creator.errors raise ex end else topic = Topic.find_by(id: @opts[:topic_id]) if (topic.blank? || !guardian.can_create?(Post, topic)) @errors = Post.new.errors @errors.add(:base, I18n.t(:topic_not_found)) raise ActiveRecord::Rollback.new end end @topic = topic end def update_topic_stats # Update attributes on the topic - featured users and last posted. attrs = {last_posted_at: @post.created_at, last_post_user_id: @post.user_id} attrs[:bumped_at] = @post.created_at unless @post.no_bump attrs[:word_count] = (@topic.word_count || 0) + @post.word_count attrs[:excerpt] = @post.excerpt(220, strip_links: true) if new_topic? @topic.update_attributes(attrs) end def update_topic_auto_close if @topic.auto_close_based_on_last_post && @topic.auto_close_hours @topic.set_auto_close(@topic.auto_close_hours).save end end def setup_post @opts[:raw] = TextCleaner.normalize_whitespaces(@opts[:raw]).gsub(/\s+\z/, "") post = @topic.posts.new(raw: @opts[:raw], user: @user, reply_to_post_number: @opts[:reply_to_post_number]) # Attributes we pass through to the post instance if present [:post_type, :no_bump, :cooking_options, :image_sizes, :acting_user, :invalidate_oneboxes, :cook_method, :via_email, :raw_email].each do |a| post.send("#{a}=", @opts[a]) if @opts[a].present? end post.extract_quoted_post_numbers post.created_at = Time.zone.parse(@opts[:created_at].to_s) if @opts[:created_at].present? if fields = @opts[:custom_fields] post.custom_fields = fields end @post = post end def rollback_if_host_spam_detected return if skip_validations? if @post.has_host_spam? @post.errors.add(:base, I18n.t(:spamming_host)) @errors = @post.errors @spam = true raise ActiveRecord::Rollback.new end end def save_post unless @post.save(validate: !skip_validations?) @errors = @post.errors raise ActiveRecord::Rollback.new end end def store_unique_post_key @post.store_unique_post_key end def update_user_counts @user.create_user_stat if @user.user_stat.nil? if @user.user_stat.first_post_created_at.nil? @user.user_stat.first_post_created_at = @post.created_at end @user.user_stat.post_count += 1 @user.user_stat.topic_count += 1 if @post.post_number == 1 # We don't count replies to your own topics if !@opts[:import_mode] && @user.id != @topic.user_id @user.user_stat.update_topic_reply_count end @user.user_stat.save! @user.last_posted_at = @post.created_at @user.save! end def publish return if @opts[:import_mode] return unless @post.post_number > 1 @post.publish_change_to_clients! :created end def extract_links TopicLink.extract_from(@post) QuotedPost.extract_from(@post) end def track_topic return if @opts[:auto_track] == false TopicUser.change(@post.user_id, @topic.id, posted: true, last_read_post_number: @post.post_number, highest_seen_post_number: @post.post_number) # assume it took us 5 seconds of reading time to make a post PostTiming.record_timing(topic_id: @post.topic_id, user_id: @post.user_id, post_number: @post.post_number, msecs: 5000) TopicUser.auto_track(@user.id, @topic.id, TopicUser.notification_reasons[:created_post]) end def enqueue_jobs return unless @post && !@post.errors.present? PostJobsEnqueuer.new(@post, @topic, new_topic?, {import_mode: @opts[:import_mode]}).enqueue_jobs end def new_topic? @opts[:topic_id].blank? end end