Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better History Control - Rollbacks and Revertions (215, 340) #1144

Draft
wants to merge 85 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
85 commits
Select commit Hold shift + click to select a range
e3c7f04
Add close reason as comment to history
Taeir Jul 21, 2023
0f75cb5
Use comment scrubber to disallow a few more elements
Taeir Jul 21, 2023
a785b41
Disallow some more markdown and remove newlines from comments
Taeir Jul 21, 2023
f18c526
Add basis of rolling back history elements
Taeir Jul 21, 2023
15a5924
Add predecessor finding
Taeir Jul 21, 2023
da318a8
Cleanup & fix when elements remain the same
Taeir Jul 21, 2023
2d922a1
Fix bug
Taeir Jul 22, 2023
19873ec
Add extra JSON column to post history
Taeir Jul 22, 2023
a366dfe
Store info into history where we can
Taeir Jul 22, 2023
582a057
Merge branch '215-record-close-reason-in-history' into 340-rollback-s…
Taeir Jul 22, 2023
7497328
Store more information and add proper display of rollbacks
Taeir Jul 22, 2023
405eb45
Add extra message constructing
Taeir Jul 22, 2023
3f1b238
Remove extra message construction
Taeir Jul 22, 2023
ac24fd9
Add logic for reapplication
Taeir Jul 22, 2023
9c67321
Change approach
Taeir Jul 22, 2023
513a390
Cleanup
Taeir Jul 22, 2023
d9e48d4
Add missing dependent destroy
Taeir Jul 22, 2023
c61d908
TODO add authentication based on rollback type
Taeir Jul 22, 2023
2c6773a
Add permission checks
Taeir Jul 23, 2023
b055300
Add beginnings of revertion
Taeir Jul 23, 2023
dfa1010
Add revertion logic
Taeir Jul 28, 2023
a40529e
Extract permission checks into submethods to reduce complexity
Taeir Jul 31, 2023
4a933c7
Change from JSON field to individual fields
Taeir Jul 31, 2023
957d199
Add restoration to specific event logic
Taeir Jul 31, 2023
89eede1
Add localization strings for new errors
Taeir Jul 31, 2023
c483131
Merge branch 'history-redaction' into revert-to-state-support
Taeir Jul 31, 2023
58937f5
Add logic for rolling back history hidden
Taeir Jul 31, 2023
7bd8d46
Fix bug in find_predecessor
Taeir Jul 31, 2023
bfa08f2
Optimize migration for large datasets
Taeir Jul 31, 2023
b95832b
Load post associated with history item
Taeir Aug 1, 2023
7888aec
Split methods into multiple smaller ones
Taeir Aug 1, 2023
22c9388
Add reverted_with information for revertions
Taeir Aug 1, 2023
eb6fed1
Add history revealed
Taeir Aug 1, 2023
ea07598
Add revert to button
Taeir Aug 1, 2023
a05abcc
Merge branch 'develop' into revert-to-state-support
Taeir Aug 1, 2023
8ce2546
Use revision consistently
Taeir Aug 4, 2023
bb7a4f7
Merge branch 'develop' into revert-to-state-support
Taeir Aug 4, 2023
38b6f79
Add test case
Taeir Aug 4, 2023
cb1e328
Add additional test cases for edit rollback
Taeir Aug 4, 2023
d57f807
More find_predecessor to PostHistory
Taeir Aug 4, 2023
b59bd13
Add close reason and duplicate post belongs_to relations
Taeir Aug 4, 2023
f97f601
Fix privilege error
Taeir Aug 4, 2023
6171828
Fix close reason not registered upon rollback
Taeir Aug 4, 2023
95d70cf
Attribute rollback events to current user
Taeir Aug 4, 2023
7b567bd
Add tests for rollbacks
Taeir Aug 4, 2023
e45b534
Fix comments tests
Taeir Aug 4, 2023
01e04c2
Fix test
Taeir Aug 4, 2023
80926c3
Merge branch 'develop' into revert-to-state-support
Taeir Aug 4, 2023
2e5b249
Fix history revealing not revealing preceding hide event itself
Taeir Aug 4, 2023
23fc5b4
Allow history_revelead to be rolled back
Taeir Aug 4, 2023
c06886b
Add tests for rollback close/delete
Taeir Aug 4, 2023
a77b8f6
Add revert history overview
Taeir Aug 6, 2023
d5a4781
Add support for undoing revealing history
Taeir Aug 6, 2023
cb4a8bb
Fix closing a post does not store close reason in history
Taeir Aug 6, 2023
f94846d
Rename rollback to undo, revert_to to roll_back_to
Taeir Aug 6, 2023
b7c265e
Update style with input from @ArtOfCode-
Taeir Aug 6, 2023
822a961
Merge branch 'develop' into revert-to-state-support
Taeir Aug 6, 2023
5a24a64
Fix bug
Taeir Aug 6, 2023
edd9718
Fix various issues and improve efficiency
Taeir Aug 6, 2023
e3aaa49
Handle empty changeset
Taeir Aug 6, 2023
6f9cbd4
Deal with empty edit comment
Taeir Aug 6, 2023
1e5b8e0
Add edit comment to events upon rollback
Taeir Aug 6, 2023
4913715
Actually render success
Taeir Aug 6, 2023
8755c59
Cleanup changes to ensure it actually contains changes to make
Taeir Aug 6, 2023
b7a0e18
Support deletion of post history again
Taeir Aug 6, 2023
1a773bc
Small bugfix for tags changes
Taeir Aug 6, 2023
7d6f9be
Improve overview display
Taeir Aug 6, 2023
85563f2
Fix reopen privileges
Taeir Aug 6, 2023
c9428b8
Add undo
Taeir Aug 6, 2023
f376fa1
Remove support for rolling back close/delete events
Taeir Aug 6, 2023
6f07f95
Extra post updates into PostActions concern
Taeir Aug 7, 2023
a2bcf0a
Improve text of actions on post history overview
Taeir Aug 7, 2023
0f9a366
Fix page name
Taeir Aug 7, 2023
f7285e1
Make terminology consistent
Taeir Aug 7, 2023
bae62a1
Fix cannot rollback to initial revision
Taeir Aug 7, 2023
0d83a15
Add warning when rolling back to hidden events
Taeir Aug 7, 2023
bb18e29
Consider history hiding in allowing rollbacks
Taeir Aug 7, 2023
8955a3d
Remove outdated tests
Taeir Aug 7, 2023
356187c
Add confirmation
Taeir Aug 7, 2023
a7f25f5
Fix revert update
Taeir Aug 7, 2023
4b9d2d3
Fix undo
Taeir Aug 7, 2023
4acf18c
Fix tests
Taeir Aug 7, 2023
310fa9c
Fix rubocop issue
Taeir Aug 7, 2023
c75dbf8
Add test for rollbacks
Taeir Aug 7, 2023
c313035
Simplify test
Taeir Aug 7, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions app/assets/stylesheets/post_history.scss
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,24 @@

& > summary {
border-bottom: 0;
display: unset;
font-weight: unset;
color: unset;

display: flex;
flex-direction: column;

@media screen and (min-width: $screen-lg) {
flex-direction: row;
justify-content: space-between;
}

& > div {
margin: 0.2em 0;

@media screen and (min-width: $screen-lg) {
margin: 0;
}
}
}

&:last-of-type {
Expand All @@ -30,4 +45,4 @@
padding: 0.5em;
background: lighten($success, 45%);
}
}
}
38 changes: 38 additions & 0 deletions app/controllers/concerns/post_actions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
module PostActions
extend ActiveSupport::Concern

# Updates the given post with the given params and creates the corresponding events.
#
# @param post [Post]
# @param user [User]
# @param params [Hash]
# @param body_rendered [String, Nil]
# @param comment [String, Nil]
# @return [PostHistory, False] if the update succeeded, the history event, false otherwise
def update_post(post, user, params, body_rendered, comment: nil)
before = { body: @post.body_markdown, title: @post.title, tags: @post.tags.to_a }

params[:body] = body_rendered if body_rendered
params = params.merge(last_edited_at: DateTime.now, last_edited_by: user,
last_activity: DateTime.now, last_activity_by: user)

if post.update(params)
PostHistory.post_edited(post, user, comment: comment,
before: before[:body], after: post.body_markdown,
before_title: before[:title], after_title: post.title,
before_tags: before[:tags], after_tags: post.tags)
else
false
end
end

# Whether the given user is allowed to directly edit the given post.
#
# @param user [User]
# @param post [Post]
# @param post_type [PostType]
def can_update_post?(user, post, post_type)
user.privilege?('edit_posts') || user.is_moderator || user == post.user ||
(post_type.is_freely_editable && user.privilege?('unrestricted'))
end
end
282 changes: 282 additions & 0 deletions app/controllers/post_history_controller.rb
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
class PostHistoryController < ApplicationController
include PostActions

def post
@post = Post.find(params[:id])

Expand All @@ -11,4 +13,284 @@ def post
.order(created_at: :desc, id: :desc).paginate(per_page: 20, page: params[:page])
render layout: 'without_sidebar'
end

def undo
@history = PostHistory.find(params[:id])
@post = @history.post
histories = @post.post_histories.order(created_at: :asc).ids
index = histories.index(@history.id) + 1

unless @history.can_undo?
flash[:danger] = 'You cannot undo this particular history element.'
redirect_to post_history_url(@post)
return
end

msg = helpers.disallow_undo_history(@history, current_user)
if msg.present?
flash[:danger] = "You are not allowed to undo this history element: #{msg}"
redirect_to post_history_url(@post)
return
end

opts = {
before: @post.body_markdown, before_title: @post.title, before_tags: @post.tags.to_a,
comment: "Undo of [##{index}: #{@history.post_history_type.name.humanize}]" \
"(#{post_history_url(@post, anchor: index)})"
}

PostHistory.transaction do
unless undo_post_history(@history)
flash[:danger] = "Unable to undo revision: #{@post.errors.full_messages.join(', ')}"
redirect_to post_history_path(@post)
return
end

opts = opts.merge(after: @post.body_markdown, after_title: @post.title, after_tags: @post.tags.to_a)

undo_type = @history.post_history_type.name_inverted

# Record in the history that this element was rolled back
new_history = PostHistory.send(undo_type, @post, current_user, **opts)

# Set the original to be rolled back
@history.reverted_with_id = new_history.id
@history.save!
end

flash[:success] = 'History successfully rolled back'
redirect_to post_history_path(@post)
end

def rollback_overview
@history = PostHistory.find(params[:id])
@post = @history.post

@changes = determine_changes_to_restore(@post, @history)

# Check whether we would actually be making changes
if @changes.empty?
flash[:warning] = 'You cannot rollback to this history element: no changes would be made.'
redirect_to post_history_url(@post)
return
end

# Check whether the user is allowed to make those changes
unless can_update_post?(current_user, @post, @post.post_type)
flash[:danger] = ability_err_msg(:edit_posts, 'edit this post')
redirect_to post_history_url(@post)
return
end

@full_history = @post.post_histories
.includes(:post_history_type, :user, post_history_tags: [:tag])
.order(created_at: :desc, id: :desc)
events_to_undo = determine_edit_events_to_undo(@post, @history)
@any_hidden = events_to_undo.any?(&:hidden)
@undo_history_ids = events_to_undo.ids

render layout: 'without_sidebar'
end

def rollback_to
@history = PostHistory.find(params[:id])
@post = @history.post

comment = params[:edit_comment]
if comment.blank?
flash[:danger] = 'You need to provide a comment for why you are rolling back.'
redirect_to rollback_overview_post_history_path(@post, @history)
return
end

# Determine the changes that we need to make
to_change = determine_changes_to_restore(@post, @history)

if to_change.empty?
flash[:warning] = 'You cannot revert to this history element: no changes would be made.'
redirect_to post_history_url(@post)
return
end

# Check whether the user is allowed to make those changes
msg = helpers.disallow_roll_back_to_history(@history, current_user)
if msg
flash[:danger] = msg
redirect_to post_history_url(@post)
return
end

# Actually apply the changes
revert_to_state(to_change, @post, @history, comment)

flash[:success] = 'Successfully rolled back.'
redirect_to post_history_url(@post)
end

protected

# Reverts the given post to the state of the given history item by using the aggregated changes from to_change
# @param to_change [Hash] the changes to make, output of `determine_changes_to_restore`
# @param post [Post]
# @param history [PostHistory]
def revert_to_state(to_change, post, history, comment)
index = post.post_histories.order(created_at: :desc, id: :desc).ids.index(history.id)
revert_comment = "Rolling back to [##{index}: #{history.post_history_type.name.humanize}]" \
"(#{post_history_url(post, anchor: index)}): #{comment}"

change_params = {}
change_params[:title] = to_change[:title] if to_change[:title].present?
change_params[:tags_cache] = to_change[:tags].map(&:name) if to_change[:tags].present?

body_rendered = nil
if to_change[:body].present?
change_params[:body_markdown] = to_change[:body]
body_rendered = ApplicationController.helpers.render_markdown(to_change[:body])
end

edit_event = update_post(post, current_user, change_params, body_rendered, comment: revert_comment)

revert_history_items(history, edit_event)
end

# Updates the post histories by setting by which event they were reverted.
# @param history [PostHistory]
# @param edit_event [PostHistory, Nil]
def revert_history_items(history, edit_event)
determine_edit_events_to_undo(history.post, history)
.update_all(reverted_with_id: edit_event, updated_at: DateTime.now)
end

# Determines the edit events to undo to revert to the given history item.
# Events are ordered in the order in which they need to be undone (newest to oldest).
#
# @param post [Post]
# @param history [PostHistory] the history item to revert the state to
def determine_edit_events_to_undo(post, history)
post.post_histories
.where(created_at: (history.created_at + 1.second)..)
.where(post_history_type: PostHistoryType.where(name: 'post_edited'))
.includes(:post_history_type)
.order(created_at: :desc, id: :desc)
end

# Determines the set of changes to apply to the post to revert back to the state it had at the given history item.
#
# The returned hash can contain:
# deleted [Boolean] - whether the post should now be deleted
# closed [Boolean] - whether the post should be closed
# title [String] - the title the post should be updated to have
# body [String] - the body the post should be updated to have
# tags [Array<Tag>] - the tags the post should be updated to have
# close_reason [CloseReason] - the close reason that should be set
# duplicate_post [Post] - the post of which this post is a duplicate that should be set
#
# @param post [Post]
# @param history [PostHistory]
# @return [Hash]
def determine_changes_to_restore(post, history)
events_to_undo = determine_edit_events_to_undo(post, history)

# Aggregate changes from events
to_change = {}
events_to_undo.each do |ph|
case ph.post_history_type.name
when 'post_edited'
to_change[:title] = ph.before_title if ph.before_title.present?
to_change[:body] = ph.before_state if ph.before_state.present?
to_change[:tags] = ph.before_tags if ph.before_tags.present?
end
end

# Cleanup changes that are already present
to_change.delete(:title) if to_change[:title] == post.title
to_change.delete(:body) if to_change[:body] == post.body_markdown
to_change.delete(:tags) if to_change[:tags]&.map(&:id)&.sort == post.tags.ids.sort

to_change
end

private

# Undoes the given history item.
# @param history [PostHistory]
# @return [Boolean] whether the undo was successfully applied
def undo_post_history(history)
post = history.post
case history.post_history_type.name
when 'post_edited'
undo_post_edit(post, history)
when 'history_hidden'
undo_hide_history(post, history)
when 'history_revealed'
undo_reveal_history(post, history)
else
false
end
end

# @param post [Post]
# @param history [PostHistory]
# @return [Boolean] whether the undo was successfully applied
def undo_post_edit(post, history)
post.title = history.before_title if history.before_title && history.before_title != history.after_title
if history.before_state && history.before_state != history.after_state
post.body_markdown = history.before_state
post.body = ApplicationController.helpers.render_markdown(history.before_state)
end
post.tags_cache += history.tags_removed.map(&:name)
post.tags_cache -= history.tags_added.map(&:name)
post.last_activity = DateTime.now
post.last_activity_by = current_user
post.save
end

# @param post [Post]
# @param history [PostHistory]
# @return [Boolean] whether the undo was successfully applied
def undo_hide_history(post, history)
# We need to reveal history for all events that were hidden by this action
histories_to_reveal = post.post_histories
.where(created_at: ..history.created_at)
.where(hidden: true)
.includes(:post_history_type)
.order(created_at: :desc, id: :desc)

# If there are more history hiding events, only hide until the previous one.
# We need to add one second because we don't want to reveal the edit before the history_hidden event, which
# will have likely occurred in the same second.
# Note that we do want to include the history_hidden item itself, so we add that with or.
predecessor = history.find_predecessor('history_hidden')
if predecessor
histories_to_reveal = histories_to_reveal.where(created_at: (predecessor.created_at + 1.second)..)
.or(PostHistory.where(id: predecessor))
end

histories_to_reveal.update_all(hidden: false, updated_at: DateTime.now).positive?
end

# @param post [Post]
# @param history [PostHistory]
# @return [Boolean] whether the undo was successfully applied
def undo_reveal_history(post, history)
# We need to hide history for all events that were revealed by this action
histories_to_hide = post.post_histories
.where(created_at: ..history.created_at)
.where(hidden: true)
.includes(:post_history_type)
.order(created_at: :desc, id: :desc)

# If there are more history hiding events, only reveal until the previous previous hiding.
# That is, we revealed hiding 1, now we are hiding again (confusing I know).
# We need to add one second because we don't want to hide the edit before the history_hidden event, which
# will have likely occurred in the same second.
# Note that we do want to include the history_hidden item itself, so we add that with or.
predecessor = history.find_predecessor('history_hidden')&.find_predecessor('history_hidden')
if predecessor
histories_to_hide = histories_to_hide.where(created_at: (predecessor.created_at + 1.second)..)
.or(PostHistory.where(id: predecessor))
end

histories_to_hide.update_all(hidden: true, updated_at: DateTime.now).positive?
end
end
Loading