FEATURE: experimental hidden setting for draft backups
Under exceptional situations the automatic draft feature can fail. This new **hidden, default off** site setting `backup_drafts_to_pm_length` will automatically backup any draft that is saved by the system to a dedicated PM (originating from self) The body of that PM will contain the text of the reply. We can enable this feature strategically on sites exhibiting issues to diagnose issues with the draft system and offer a recourse to users who appear to lose drafts. We automatically checkpoint these drafts every 5 minutes forcing a new revision each 5 minutes so you can revert to old content. Longer term we are considering automatically enabling this kind of feature for extremely long drafts where the risk is really high one could lose days of writing.
This commit is contained in:
parent
4338515a85
commit
f5d1aff8dd
|
@ -0,0 +1,23 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BackupDraftPost < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :post
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: backup_draft_posts
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# post_id :integer not null
|
||||
# key :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_backup_draft_posts_on_post_id (post_id) UNIQUE
|
||||
# index_backup_draft_posts_on_user_id_and_key (user_id,key) UNIQUE
|
||||
#
|
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class BackupDraftTopic < ActiveRecord::Base
|
||||
belongs_to :user
|
||||
belongs_to :topic
|
||||
end
|
||||
|
||||
# == Schema Information
|
||||
#
|
||||
# Table name: backup_draft_topics
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# user_id :integer not null
|
||||
# topic_id :integer not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
# index_backup_draft_topics_on_topic_id (topic_id) UNIQUE
|
||||
# index_backup_draft_topics_on_user_id (user_id) UNIQUE
|
||||
#
|
|
@ -6,6 +6,10 @@ class Draft < ActiveRecord::Base
|
|||
EXISTING_TOPIC ||= 'topic_'
|
||||
|
||||
def self.set(user, key, sequence, data)
|
||||
if SiteSetting.backup_drafts_to_pm_length > 0 && SiteSetting.backup_drafts_to_pm_length < data.length
|
||||
backup_draft(user, key, sequence, data)
|
||||
end
|
||||
|
||||
if d = find_draft(user, key)
|
||||
return if d.sequence > sequence
|
||||
|
||||
|
@ -96,6 +100,92 @@ class Draft < ActiveRecord::Base
|
|||
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(:post_id).first
|
||||
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.updated_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(:topic_id).first
|
||||
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
|
||||
|
|
|
@ -871,6 +871,9 @@ en:
|
|||
short_description: "Like this post"
|
||||
long_form: "liked this"
|
||||
|
||||
draft_backup:
|
||||
pm_title: "Backup Drafts from ongoing topics"
|
||||
pm_body: "Topic containing backup drafts"
|
||||
user_activity:
|
||||
no_default:
|
||||
self: "You have no activity yet."
|
||||
|
@ -1993,7 +1996,7 @@ en:
|
|||
notify_about_flags_after: "If there are flags that haven't been handled after this many hours, send a personal message to moderators. Set to 0 to disable."
|
||||
show_create_topics_notice: "If the site has fewer than 5 public topics, show a notice asking admins to create some topics."
|
||||
|
||||
delete_drafts_older_than_n_days: Delete drafts older than (n) days.
|
||||
delete_drafts_older_than_n_days: "Delete drafts older than (n) days."
|
||||
|
||||
bootstrap_mode_min_users: "Minimum number of users required to disable bootstrap mode (set to 0 to disable)"
|
||||
|
||||
|
|
|
@ -1858,6 +1858,10 @@ uncategorized:
|
|||
delete_drafts_older_than_n_days:
|
||||
default: 180
|
||||
|
||||
backup_drafts_to_pm_length:
|
||||
default: 0
|
||||
hidden: true
|
||||
|
||||
tos_topic_id:
|
||||
default: -1
|
||||
hidden: true
|
||||
|
|
|
@ -0,0 +1,25 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddDraftBackupTables < ActiveRecord::Migration[6.0]
|
||||
def change
|
||||
|
||||
create_table :backup_draft_topics do |t|
|
||||
t.integer :user_id, null: false
|
||||
t.integer :topic_id, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
create_table :backup_draft_posts do |t|
|
||||
t.integer :user_id, null: false
|
||||
t.integer :post_id, null: false
|
||||
t.string :key, null: false
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :backup_draft_posts, [:user_id, :key], unique: true
|
||||
add_index :backup_draft_posts, [:post_id], unique: true
|
||||
|
||||
add_index :backup_draft_topics, [:user_id], unique: true
|
||||
add_index :backup_draft_topics, [:topic_id], unique: true
|
||||
end
|
||||
end
|
|
@ -34,6 +34,7 @@ class PostCreator
|
|||
# dequeue before the commit finishes. If you do this, be sure to
|
||||
# call `enqueue_jobs` after the transaction is comitted.
|
||||
# hidden_reason_id - Reason for hiding the post (optional)
|
||||
# skip_validations - Do not validate any of the content in the post
|
||||
#
|
||||
# When replying to a topic:
|
||||
# topic_id - topic we're replying to
|
||||
|
|
|
@ -3,37 +3,70 @@
|
|||
require 'rails_helper'
|
||||
|
||||
describe Draft do
|
||||
before do
|
||||
@user = Fabricate(:user)
|
||||
|
||||
fab!(:user) do
|
||||
Fabricate(:user)
|
||||
end
|
||||
|
||||
context 'backup_drafts_to_pm_length' do
|
||||
it "correctly backs up drafts to a personal message" do
|
||||
SiteSetting.backup_drafts_to_pm_length = 1
|
||||
|
||||
draft = {
|
||||
reply: "this is a reply",
|
||||
random_key: "random"
|
||||
}
|
||||
|
||||
Draft.set(user, "new_private_message", 0, draft.to_json)
|
||||
draft["reply"] = "test" * 100
|
||||
Draft.set(user, "new_private_message", 77, draft.to_json)
|
||||
|
||||
draft_post = BackupDraftPost.find_by(user_id: user.id, key: "new_private_message").post
|
||||
|
||||
expect(draft_post.revisions.count).to eq(0)
|
||||
|
||||
freeze_time 10.minutes.from_now
|
||||
|
||||
# this should trigger a post revision as 10 minutes have passed
|
||||
draft["reply"] = "hello"
|
||||
Draft.set(user, "new_private_message", 77, draft.to_json)
|
||||
|
||||
draft_topic = BackupDraftTopic.find_by(user_id: user.id)
|
||||
expect(draft_topic.topic.posts_count).to eq(2)
|
||||
|
||||
draft_post.reload
|
||||
expect(draft_post.revisions.count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
it "can get a draft by user" do
|
||||
Draft.set(@user, "test", 0, "data")
|
||||
expect(Draft.get(@user, "test", 0)).to eq "data"
|
||||
Draft.set(user, "test", 0, "data")
|
||||
expect(Draft.get(user, "test", 0)).to eq "data"
|
||||
end
|
||||
|
||||
it "uses the user id and key correctly" do
|
||||
Draft.set(@user, "test", 0, "data")
|
||||
Draft.set(user, "test", 0, "data")
|
||||
expect(Draft.get(Fabricate.build(:coding_horror), "test", 0)).to eq nil
|
||||
end
|
||||
|
||||
it "should overwrite draft data correctly" do
|
||||
Draft.set(@user, "test", 0, "data")
|
||||
Draft.set(@user, "test", 0, "new data")
|
||||
expect(Draft.get(@user, "test", 0)).to eq "new data"
|
||||
Draft.set(user, "test", 0, "data")
|
||||
Draft.set(user, "test", 0, "new data")
|
||||
expect(Draft.get(user, "test", 0)).to eq "new data"
|
||||
end
|
||||
|
||||
it "should clear drafts on request" do
|
||||
Draft.set(@user, "test", 0, "data")
|
||||
Draft.clear(@user, "test", 0)
|
||||
expect(Draft.get(@user, "test", 0)).to eq nil
|
||||
Draft.set(user, "test", 0, "data")
|
||||
Draft.clear(user, "test", 0)
|
||||
expect(Draft.get(user, "test", 0)).to eq nil
|
||||
end
|
||||
|
||||
it "should disregard old draft if sequence decreases" do
|
||||
Draft.set(@user, "test", 0, "data")
|
||||
Draft.set(@user, "test", 1, "hello")
|
||||
Draft.set(@user, "test", 0, "foo")
|
||||
expect(Draft.get(@user, "test", 0)).to eq nil
|
||||
expect(Draft.get(@user, "test", 1)).to eq "hello"
|
||||
Draft.set(user, "test", 0, "data")
|
||||
Draft.set(user, "test", 1, "hello")
|
||||
Draft.set(user, "test", 0, "foo")
|
||||
expect(Draft.get(user, "test", 0)).to eq nil
|
||||
expect(Draft.get(user, "test", 1)).to eq "hello"
|
||||
end
|
||||
|
||||
it 'can cleanup old drafts' do
|
||||
|
@ -71,25 +104,25 @@ describe Draft do
|
|||
let(:public_topic) { public_post.topic }
|
||||
|
||||
let(:stream) do
|
||||
Draft.stream(user: @user)
|
||||
Draft.stream(user: user)
|
||||
end
|
||||
|
||||
it "should include the correct number of drafts in the stream" do
|
||||
Draft.set(@user, "test", 0, '{"reply":"hey.","action":"createTopic","title":"Hey"}')
|
||||
Draft.set(@user, "test2", 0, '{"reply":"howdy"}')
|
||||
Draft.set(user, "test", 0, '{"reply":"hey.","action":"createTopic","title":"Hey"}')
|
||||
Draft.set(user, "test2", 0, '{"reply":"howdy"}')
|
||||
expect(stream.count).to eq(2)
|
||||
end
|
||||
|
||||
it "should include the right topic id in a draft reply in the stream" do
|
||||
Draft.set(@user, "topic_#{public_topic.id}", 0, '{"reply":"hi"}')
|
||||
Draft.set(user, "topic_#{public_topic.id}", 0, '{"reply":"hi"}')
|
||||
draft_row = stream.first
|
||||
expect(draft_row.topic_id).to eq(public_topic.id)
|
||||
end
|
||||
|
||||
it "should include the right draft username in the stream" do
|
||||
Draft.set(@user, "topic_#{public_topic.id}", 0, '{"reply":"hey"}')
|
||||
Draft.set(user, "topic_#{public_topic.id}", 0, '{"reply":"hey"}')
|
||||
draft_row = stream.first
|
||||
expect(draft_row.draft_username).to eq(@user.username)
|
||||
expect(draft_row.draft_username).to eq(user.username)
|
||||
end
|
||||
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue