diff --git a/app/assets/javascripts/discourse/controllers/history.js.es6 b/app/assets/javascripts/discourse/controllers/history.js.es6
index f799700046d..52d0d1de8c7 100644
--- a/app/assets/javascripts/discourse/controllers/history.js.es6
+++ b/app/assets/javascripts/discourse/controllers/history.js.es6
@@ -29,6 +29,25 @@ export default Ember.Controller.extend(ModalFunctionality, {
Discourse.Post.showRevision(postId, postVersion).then(() => this.refresh(postId, postVersion));
},
+ revert(post, postVersion) {
+ post.revertToRevision(postVersion).then((result) => {
+ this.refresh(post.get('id'), postVersion);
+ if (result.topic) {
+ post.set('topic.slug', result.topic.slug);
+ post.set('topic.title', result.topic.title);
+ post.set('topic.fancy_title', result.topic.fancy_title);
+ }
+ if (result.category_id) {
+ post.set('topic.category', Discourse.Category.findById(result.category_id));
+ }
+ this.send("closeModal");
+ }).catch(function(e) {
+ if (e.jqXHR.responseJSON && e.jqXHR.responseJSON.errors && e.jqXHR.responseJSON.errors[0]) {
+ bootbox.alert(e.jqXHR.responseJSON.errors[0]);
+ }
+ });
+ },
+
@computed('model.created_at')
createdAtDate(createdAt) {
return moment(createdAt).format("LLLL");
@@ -69,6 +88,11 @@ export default Ember.Controller.extend(ModalFunctionality, {
return !prevHidden && this.currentUser && this.currentUser.get('staff');
},
+ @computed()
+ displayRevert() {
+ return this.currentUser && this.currentUser.get('staff');
+ },
+
isEitherRevisionHidden: Ember.computed.or("model.previous_hidden", "model.current_hidden"),
@computed('model.previous_hidden', 'model.current_hidden', 'displayingInline')
@@ -142,6 +166,8 @@ export default Ember.Controller.extend(ModalFunctionality, {
hideVersion() { this.hide(this.get("model.post_id"), this.get("model.current_revision")); },
showVersion() { this.show(this.get("model.post_id"), this.get("model.current_revision")); },
+ revertToVersion() { this.revert(this.get("post"), this.get("model.current_revision")); },
+
displayInline() { this.set("viewMode", "inline"); },
displaySideBySide() { this.set("viewMode", "side_by_side"); },
displaySideBySideMarkdown() { this.set("viewMode", "side_by_side_markdown"); }
diff --git a/app/assets/javascripts/discourse/models/post.js.es6 b/app/assets/javascripts/discourse/models/post.js.es6
index 8565cfc7d1b..37005bc993d 100644
--- a/app/assets/javascripts/discourse/models/post.js.es6
+++ b/app/assets/javascripts/discourse/models/post.js.es6
@@ -271,6 +271,10 @@ const Post = RestModel.extend({
json = Post.munge(json);
this.set('actions_summary', json.actions_summary);
}
+ },
+
+ revertToRevision(version) {
+ return Discourse.ajax(`/posts/${this.get('id')}/revisions/${version}/revert`, { type: 'PUT' });
}
});
diff --git a/app/assets/javascripts/discourse/routes/topic.js.es6 b/app/assets/javascripts/discourse/routes/topic.js.es6
index 6e17a41b5b1..4297186f07f 100644
--- a/app/assets/javascripts/discourse/routes/topic.js.es6
+++ b/app/assets/javascripts/discourse/routes/topic.js.es6
@@ -73,6 +73,7 @@ const TopicRoute = Discourse.Route.extend({
showHistory(model) {
showModal('history', { model });
this.controllerFor('history').refresh(model.get("id"), "latest");
+ this.controllerFor('history').set('post', model);
this.controllerFor('modal').set('modalClass', 'history-modal');
},
diff --git a/app/assets/javascripts/discourse/templates/modal/history.hbs b/app/assets/javascripts/discourse/templates/modal/history.hbs
index bcf20fbc1ca..21e16627586 100644
--- a/app/assets/javascripts/discourse/templates/modal/history.hbs
+++ b/app/assets/javascripts/discourse/templates/modal/history.hbs
@@ -10,12 +10,6 @@
{{d-button action="loadNextVersion" icon="forward" title="post.revisions.controls.next" disabled=loadNextDisabled}}
{{d-button action="loadLastVersion" icon="fast-forward" title="post.revisions.controls.last" disabled=loadLastDisabled}}
- {{#if displayHide}}
- {{d-button action="hideVersion" icon="trash-o" title="post.revisions.controls.hide" class="btn-danger" disabled=loading}}
- {{/if}}
- {{#if displayShow}}
- {{d-button action="showVersion" icon="undo" title="post.revisions.controls.show" disabled=loading}}
- {{/if}}
{{d-button action="displayInline" label="post.revisions.displays.inline.button" title="post.revisions.displays.inline.title" class=inlineClass}}
@@ -85,5 +79,15 @@
{{{bodyDiff}}}
+
+ {{#if displayRevert}}
+ {{d-button action="revertToVersion" icon="undo" label="post.revisions.controls.revert" class="btn-danger" disabled=loading}}
+ {{/if}}
+ {{#if displayHide}}
+ {{d-button action="hideVersion" icon="eye-slash" label="post.revisions.controls.hide" class="btn-danger" disabled=loading}}
+ {{/if}}
+ {{#if displayShow}}
+ {{d-button action="showVersion" icon="eye" label="post.revisions.controls.show" disabled=loading}}
+ {{/if}}
diff --git a/app/controllers/posts_controller.rb b/app/controllers/posts_controller.rb
index f9800e885b7..fdf3dbb687d 100644
--- a/app/controllers/posts_controller.rb
+++ b/app/controllers/posts_controller.rb
@@ -282,6 +282,55 @@ class PostsController < ApplicationController
render nothing: true
end
+ def revert
+ raise Discourse::NotFound unless guardian.is_staff?
+
+ post_id = params[:id] || params[:post_id]
+ revision = params[:revision].to_i
+ raise Discourse::InvalidParameters.new(:revision) if revision < 2
+
+ post_revision = PostRevision.find_by(post_id: post_id, number: revision)
+ raise Discourse::NotFound unless post_revision
+
+ post = find_post_from_params
+ raise Discourse::NotFound if post.blank?
+
+ post_revision.post = post
+ guardian.ensure_can_see!(post_revision)
+ guardian.ensure_can_edit!(post)
+ return render_json_error(I18n.t('revert_version_same')) if post_revision.modifications["raw"].blank? && post_revision.modifications["title"].blank? && post_revision.modifications["category_id"].blank?
+
+ topic = Topic.with_deleted.find(post.topic_id)
+
+ changes = {}
+ changes[:raw] = post_revision.modifications["raw"][0] if post_revision.modifications["raw"].present? && post_revision.modifications["raw"][0] != post.raw
+ if post.is_first_post?
+ changes[:title] = post_revision.modifications["title"][0] if post_revision.modifications["title"].present? && post_revision.modifications["title"][0] != topic.title
+ changes[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present? && post_revision.modifications["category_id"][0] != topic.category.id
+ end
+ return render_json_error(I18n.t('revert_version_same')) unless changes.length > 0
+ changes[:edit_reason] = "reverted to version ##{post_revision.number.to_i - 1}"
+
+ revisor = PostRevisor.new(post, topic)
+ revisor.revise!(current_user, changes)
+
+ return render_json_error(post) if post.errors.present?
+ return render_json_error(topic) if topic.errors.present?
+
+ post_serializer = PostSerializer.new(post, scope: guardian, root: false)
+ post_serializer.draft_sequence = DraftSequence.current(current_user, topic.draft_key)
+ link_counts = TopicLink.counts_for(guardian, topic, [post])
+ post_serializer.single_post_link_counts = link_counts[post.id] if link_counts.present?
+
+ result = { post: post_serializer.as_json }
+ if post.is_first_post?
+ result[:topic] = BasicTopicSerializer.new(topic, scope: guardian, root: false).as_json if post_revision.modifications["title"].present?
+ result[:category_id] = post_revision.modifications["category_id"][0] if post_revision.modifications["category_id"].present?
+ end
+
+ render_json_dump(result)
+ end
+
def bookmark
post = find_post_from_params
diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml
index 16834c89441..110af931408 100644
--- a/config/locales/client.en.yml
+++ b/config/locales/client.en.yml
@@ -1639,6 +1639,7 @@ en:
last: "Last revision"
hide: "Hide revision"
show: "Show revision"
+ revert: "Revert to this revision"
comparing_previous_to_current_out_of_total: "{{previous}} {{current}} / {{total}}"
displays:
inline:
diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml
index 8494bb3e0a0..d3a05ffa941 100644
--- a/config/locales/server.en.yml
+++ b/config/locales/server.en.yml
@@ -205,6 +205,7 @@ en:
top: "Top topics"
posts: "Latest posts"
too_late_to_edit: "That post was created too long ago. It can no longer be edited or deleted."
+ revert_version_same: "The current version is same as the version you are trying to revert to."
excerpt_image: "image"
diff --git a/config/routes.rb b/config/routes.rb
index d28299a7bd4..db70aabf1a9 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -386,6 +386,7 @@ Discourse::Application.routes.draw do
get "revisions/:revision" => "posts#revisions", constraints: { revision: /\d+/ }
put "revisions/:revision/hide" => "posts#hide_revision", constraints: { revision: /\d+/ }
put "revisions/:revision/show" => "posts#show_revision", constraints: { revision: /\d+/ }
+ put "revisions/:revision/revert" => "posts#revert", constraints: { revision: /\d+/ }
put "recover"
collection do
delete "destroy_many"
diff --git a/spec/controllers/posts_controller_spec.rb b/spec/controllers/posts_controller_spec.rb
index 14896e52d64..67392c081c6 100644
--- a/spec/controllers/posts_controller_spec.rb
+++ b/spec/controllers/posts_controller_spec.rb
@@ -830,6 +830,79 @@ describe PostsController do
end
+ describe 'revert post to a specific revision' do
+ include_examples 'action requires login', :put, :revert, post_id: 123, revision: 2
+
+ let(:post) { Fabricate(:post, user: logged_in_as, raw: "Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex") }
+ let(:post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["this is original post body.", "this is edited post body."]}) }
+ let(:blank_post_revision) { Fabricate(:post_revision, post: post, modifications: {"edit_reason" => ["edit reason #1", "edit reason #2"]}) }
+ let(:same_post_revision) { Fabricate(:post_revision, post: post, modifications: {"raw" => ["Lorem ipsum dolor sit amet, cu nam libris tractatos, ancillae senserit ius ex", "this is edited post body."]}) }
+
+ let(:revert_params) do
+ {
+ post_id: post.id,
+ revision: post_revision.number
+ }
+ end
+ let(:moderator) { Fabricate(:moderator) }
+
+ describe 'when logged in as a regular user' do
+ let(:logged_in_as) { log_in }
+
+ it "does not work" do
+ xhr :put, :revert, revert_params
+ expect(response).to_not be_success
+ end
+ end
+
+ describe "when logged in as staff" do
+ let(:logged_in_as) { log_in(:moderator) }
+
+ it "throws an exception when revision is < 2" do
+ expect {
+ xhr :put, :revert, post_id: post.id, revision: 1
+ }.to raise_error(Discourse::InvalidParameters)
+ end
+
+ it "fails when post_revision record is not found" do
+ xhr :put, :revert, post_id: post.id, revision: post_revision.number + 1
+ expect(response).to_not be_success
+ end
+
+ it "fails when post record is not found" do
+ xhr :put, :revert, post_id: post.id + 1, revision: post_revision.number
+ expect(response).to_not be_success
+ end
+
+ it "fails when revision is blank" do
+ xhr :put, :revert, post_id: post.id, revision: blank_post_revision.number
+
+ expect(response.status).to eq(422)
+ expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same'))
+ end
+
+ it "fails when revised version is same as current version" do
+ xhr :put, :revert, post_id: post.id, revision: same_post_revision.number
+
+ expect(response.status).to eq(422)
+ expect(JSON.parse(response.body)['errors']).to include(I18n.t('revert_version_same'))
+ end
+
+ it "works!" do
+ xhr :put, :revert, revert_params
+ expect(response).to be_success
+ end
+
+ it "supports reverting posts in deleted topics" do
+ first_post = post.topic.ordered_posts.first
+ PostDestroyer.new(moderator, first_post).destroy
+
+ xhr :put, :revert, revert_params
+ expect(response).to be_success
+ end
+ end
+ end
+
describe 'expandable embedded posts' do
let(:post) { Fabricate(:post) }