discourse/app/models/draft.rb
Sam Saffron fc97f7e0e7
FIX: properly ban non human users from draft system
Previously we had a partial fix in place where non human users
were not allowed draft sequences, this left edges around where non
human users asked for drafts yet had none.

For example system could already have a few drafts in place.

This also removes and extensibility point we added that is not in use
2020-05-26 10:07:09 +10:00

353 lines
8.9 KiB
Ruby

# frozen_string_literal: true
class Draft < ActiveRecord::Base
NEW_TOPIC ||= 'new_topic'
NEW_PRIVATE_MESSAGE ||= 'new_private_message'
EXISTING_TOPIC ||= 'topic_'
belongs_to :user
class OutOfSequence < StandardError; end
def self.set(user, key, sequence, data, owner = nil)
return 0 if !User.human_user_id?(user.id)
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
if current_sequence != sequence
raise Draft::OutOfSequence
end
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: [: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
}
DB.exec(<<~SQL, opts)
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
SQL
end
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
if sequence != current_sequence
raise Draft::OutOfSequence
end
data if current_sequence == draft_sequence
end
def self.clear(user, key, sequence)
return if !user || !user.id || !User.human_user_id?(user.id)
current_sequence = DraftSequence.current(user, key)
# bad caller is a reason to complain
if sequence != current_sequence
raise Draft::OutOfSequence
end
# 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
JSON.parse(data)
end
def topic_id
if draft_key.starts_with?(EXISTING_TOPIC)
draft_key.gsub(EXISTING_TOPIC, "").to_i
end
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!
DB.exec(<<~SQL)
DELETE FROM drafts
WHERE 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).destroy_all
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).pluck_first(:post_id)
post = Post.where(id: post_id).first if post_id
if post_id && !post
BackupDraftPost.where(user_id: user.id, key: key).delete_all
end
indented_reply = reply.split("\n").map! do |l|
" #{l}"
end
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).pluck_first(:topic_id)
topic = Topic.find_by(id: topic_id) if topic_id
if topic_id && !topic
BackupDraftTopic.where(user_id: user.id).delete_all
end
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
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
#