mirror of
https://github.com/discourse/discourse.git
synced 2024-12-03 02:23:39 +08:00
a29ae17d3a
Previously we only changed sequence on ownership change, this cause a race condition between tabs where user could type for a long time without being warned of an out of date draft. This change is a radical change and we should watch closely. Code was already in place to track sequence on the client so no changes are needed there.
348 lines
8.8 KiB
Ruby
348 lines
8.8 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)
|
|
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)
|
|
|
|
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)
|
|
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
|
|
#
|