# frozen_string_literal: true class Draft < ActiveRecord::Base NEW_TOPIC ||= "new_topic" NEW_PRIVATE_MESSAGE ||= "new_private_message" EXISTING_TOPIC ||= "topic_" belongs_to :user has_many :upload_references, as: :target, dependent: :delete_all validates :draft_key, length: { maximum: 25 } after_commit :update_draft_count, on: %i[create destroy] class OutOfSequence < StandardError end def self.set(user, key, sequence, data, owner = nil, force_save: false) return 0 if !User.human_user_id?(user.id) force_save = force_save.to_s == "true" if SiteSetting.backup_drafts_to_pm_length > 0 && SiteSetting.backup_drafts_to_pm_length < data.length backup_draft(user, key, sequence, data) end # this is called a lot so we should micro optimize here draft_id, current_owner, current_sequence = DB.query_single(<<~SQL, user_id: user.id, key: key) WITH draft AS ( SELECT id, owner FROM drafts WHERE user_id = :user_id AND draft_key = :key ), draft_sequence AS ( SELECT sequence FROM draft_sequences WHERE user_id = :user_id AND draft_key = :key ) SELECT (SELECT id FROM draft), (SELECT owner FROM draft), (SELECT sequence FROM draft_sequence) SQL current_sequence ||= 0 if draft_id raise Draft::OutOfSequence if !force_save && (current_sequence != sequence) sequence = current_sequence if force_save sequence += 1 # we need to keep upping our sequence on every save # if we do not do that there are bad race conditions DraftSequence.upsert( { sequence: sequence, draft_key: key, user_id: user.id }, unique_by: %i[user_id draft_key], ) DB.exec(<<~SQL, id: draft_id, sequence: sequence, data: data, owner: owner || current_owner) UPDATE drafts SET sequence = :sequence , data = :data , revisions = revisions + 1 , owner = :owner , updated_at = CURRENT_TIMESTAMP WHERE id = :id SQL elsif sequence != current_sequence raise Draft::OutOfSequence else opts = { user_id: user.id, draft_key: key, data: data, sequence: sequence, owner: owner } draft_id = DB.query_single(<<~SQL, opts).first INSERT INTO drafts (user_id, draft_key, data, sequence, owner, created_at, updated_at) VALUES (:user_id, :draft_key, :data, :sequence, :owner, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (user_id, draft_key) DO UPDATE SET sequence = :sequence, data = :data, revisions = drafts.revisions + 1, owner = :owner, updated_at = CURRENT_TIMESTAMP RETURNING id SQL UserStat.update_draft_count(user.id) end UploadReference.ensure_exist!( upload_ids: Upload.extract_upload_ids(data), target_type: "Draft", target_id: draft_id, ) sequence end def self.get(user, key, sequence) return if !user || !user.id || !User.human_user_id?(user.id) opts = { user_id: user.id, draft_key: key, sequence: sequence } current_sequence, data, draft_sequence = DB.query_single(<<~SQL, opts) WITH draft AS ( SELECT data, sequence FROM drafts WHERE draft_key = :draft_key AND user_id = :user_id ), draft_sequence AS ( SELECT sequence FROM draft_sequences WHERE draft_key = :draft_key AND user_id = :user_id ) SELECT ( SELECT sequence FROM draft_sequence) , ( SELECT data FROM draft ), ( SELECT sequence FROM draft ) SQL current_sequence ||= 0 raise Draft::OutOfSequence if sequence != current_sequence data if current_sequence == draft_sequence end def self.has_topic_draft(user) return if !user || !user.id || !User.human_user_id?(user.id) Draft.where(user_id: user.id, draft_key: NEW_TOPIC).present? end def self.clear(user, key, sequence) if !user || !user.id || !User.human_user_id?(user.id) raise StandardError.new("user not present") end current_sequence = DraftSequence.current(user, key) # bad caller is a reason to complain raise Draft::OutOfSequence.new("bad draft sequence") if sequence != current_sequence # corrupt data is not a reason not to leave data Draft.where(user_id: user.id, draft_key: key).destroy_all end def display_user post&.user || topic&.user || user end def parsed_data begin JSON.parse(data) rescue JSON::ParserError {} end end def topic_id draft_key.gsub(EXISTING_TOPIC, "").to_i if draft_key.starts_with?(EXISTING_TOPIC) end def topic_preloaded? !!defined?(@topic) end def topic topic_preloaded? ? @topic : @topic = Draft.allowed_draft_topics_for_user(user).find_by(id: topic_id) end def preload_topic(topic) @topic = topic end def post_id parsed_data["postId"] end def post_preloaded? !!defined?(@post) end def post post_preloaded? ? @post : @post = Draft.allowed_draft_posts_for_user(user).find_by(id: post_id) end def preload_post(post) @post = post end def self.preload_data(drafts, user) topic_ids = drafts.map(&:topic_id) post_ids = drafts.map(&:post_id) topics = self.allowed_draft_topics_for_user(user).where(id: topic_ids) posts = self.allowed_draft_posts_for_user(user).where(id: post_ids) drafts.each do |draft| draft.preload_topic(topics.detect { |t| t.id == draft.topic_id }) draft.preload_post(posts.detect { |p| p.id == draft.post_id }) end end def self.allowed_draft_topics_for_user(user) topics = Topic.listable_topics.secured(Guardian.new(user)) pms = Topic.private_messages_for_user(user) topics.or(pms) end def self.allowed_draft_posts_for_user(user) # .secured handles whispers, merge handles topic/pm visibility Post.secured(Guardian.new(user)).joins(:topic).merge(self.allowed_draft_topics_for_user(user)) end def self.stream(opts = nil) opts ||= {} user_id = opts[:user].id offset = (opts[:offset] || 0).to_i limit = (opts[:limit] || 30).to_i stream = Draft.where(user_id: user_id).order(updated_at: :desc).offset(offset).limit(limit) # Preload posts and topics to avoid N+1 queries Draft.preload_data(stream, opts[:user]) stream end def self.cleanup! Draft.where(<<~SQL).in_batches(of: 100).destroy_all sequence < ( SELECT MAX(s.sequence) FROM draft_sequences s WHERE s.draft_key = drafts.draft_key AND s.user_id = drafts.user_id ) SQL # remove old drafts delete_drafts_older_than_n_days = SiteSetting.delete_drafts_older_than_n_days.days.ago Draft.where("updated_at < ?", delete_drafts_older_than_n_days).in_batches(of: 100).destroy_all UserStat.update_draft_count end def self.backup_draft(user, key, sequence, data) reply = JSON.parse(data)["reply"] || "" return if reply.length < SiteSetting.backup_drafts_to_pm_length post_id = BackupDraftPost.where(user_id: user.id, key: key).pick(:post_id) post = Post.where(id: post_id).first if post_id BackupDraftPost.where(user_id: user.id, key: key).delete_all if post_id && !post indented_reply = reply.split("\n").map! { |l| " #{l}" } draft_body = <<~MD #{indented_reply.join("\n")} ```text seq: #{sequence} key: #{key} ``` MD return if post && post.raw == draft_body if !post topic = ensure_draft_topic!(user) Post.transaction do post = PostCreator.new( user, raw: draft_body, skip_jobs: true, skip_validations: true, topic_id: topic.id, ).create BackupDraftPost.create!(user_id: user.id, key: key, post_id: post.id) end elsif post.last_version_at > 5.minutes.ago # bypass all validations here to maximize speed post.update_columns( raw: draft_body, cooked: PrettyText.cook(draft_body), updated_at: Time.zone.now, ) else revisor = PostRevisor.new(post, post.topic) revisor.revise!( user, { raw: draft_body }, bypass_bump: true, skip_validations: true, skip_staff_log: true, bypass_rate_limiter: true, ) end rescue => e Discourse.warn_exception(e, message: "Failed to backup draft") end def self.ensure_draft_topic!(user) topic_id = BackupDraftTopic.where(user_id: user.id).pick(:topic_id) topic = Topic.find_by(id: topic_id) if topic_id BackupDraftTopic.where(user_id: user.id).delete_all if topic_id && !topic if !topic Topic.transaction do creator = PostCreator.new( user, title: I18n.t("draft_backup.pm_title"), archetype: Archetype.private_message, raw: I18n.t("draft_backup.pm_body"), skip_jobs: true, skip_validations: true, target_usernames: user.username, ) topic = creator.create.topic BackupDraftTopic.create!(topic_id: topic.id, user_id: user.id) end end topic end def update_draft_count UserStat.update_draft_count(self.user_id) end end # == Schema Information # # Table name: drafts # # id :integer not null, primary key # user_id :integer not null # draft_key :string not null # data :text not null # created_at :datetime not null # updated_at :datetime not null # sequence :bigint default(0), not null # revisions :integer default(1), not null # owner :string # # Indexes # # index_drafts_on_user_id_and_draft_key (user_id,draft_key) UNIQUE #