mirror of
https://github.com/discourse/discourse.git
synced 2024-11-29 20:24:05 -06:00
e3a2446874
The hidden site setting max_drafts_per_user defaults to 10_000 drafts per user. The longest key should be "topic_<MAX_BIG_INT>" which is 25 characters.
362 lines
9.7 KiB
Ruby
362 lines
9.7 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
|
|
|
|
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!
|
|
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
|
|
|
|
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
|
|
#
|