active_record-undo 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'erb'
4
+
5
+ module ActiveRecord
6
+ module Undo
7
+ class ApplicationController < ActiveRecord::Undo.base_controller_class
8
+ include SafeRedirect
9
+
10
+ protect_from_forgery with: :exception, unless: -> { request.format.json? }
11
+
12
+ rescue_from ActiveRecord::Undo::SecurityError, with: :handle_security_error
13
+
14
+ private
15
+
16
+ def resolve_whodunnit_user
17
+ if respond_to?(:current_user, true) && send(:current_user).present?
18
+ send(:current_user)
19
+ elsif ActiveRecord::Undo.config.current_user_method.respond_to?(:call)
20
+ ActiveRecord::Undo.config.current_user_method.call
21
+ else
22
+ ActiveRecord::Undo.whodunnit
23
+ end
24
+ end
25
+
26
+ def resolve_current_tenant
27
+ if respond_to?(:current_tenant, true) && send(:current_tenant).present?
28
+ send(:current_tenant)
29
+ elsif ActiveRecord::Undo.config.current_tenant_method.respond_to?(:call)
30
+ ActiveRecord::Undo.config.current_tenant_method.call
31
+ else
32
+ ActiveRecord::Undo.current_tenant
33
+ end
34
+ end
35
+
36
+ def handle_security_error(error)
37
+ respond_to do |format|
38
+ format.html { handle_html_error(error.message, :forbidden) }
39
+ format.turbo_stream { render_turbo_stream_error(error.message, :forbidden) }
40
+ format.json { render json: { error: error.message }, status: :forbidden }
41
+ end
42
+ end
43
+
44
+ def handle_not_found
45
+ msg = 'Undo log not found or already restored.'
46
+ respond_to do |format|
47
+ format.html { handle_html_error(msg, :not_found) }
48
+ format.turbo_stream { render_turbo_stream_error(msg, :not_found) }
49
+ format.json { render json: { error: msg }, status: :not_found }
50
+ end
51
+ end
52
+
53
+ def handle_expired
54
+ msg = 'Undo action has expired.'
55
+ respond_to do |format|
56
+ format.html { handle_html_error(msg, :unprocessable_entity) }
57
+ format.turbo_stream { render_turbo_stream_error(msg, :unprocessable_entity) }
58
+ format.json { render json: { error: msg }, status: :unprocessable_entity }
59
+ end
60
+ end
61
+
62
+ def handle_html_error(message, status)
63
+ if should_redirect_on_error?
64
+ redirect_to determine_redirect_path, alert: message, status: :see_other
65
+ else
66
+ render plain: message, status: status
67
+ end
68
+ end
69
+
70
+ def should_redirect_on_error?
71
+ return true if ActiveRecord::Undo.config.error_handling == :redirect
72
+ return false if ActiveRecord::Undo.config.error_handling == :render
73
+
74
+ safe_redirect_path(params[:redirect_to]).present? || safe_redirect_path(request.referer).present?
75
+ end
76
+
77
+ def render_turbo_stream_error(message, status)
78
+ render inline: turbo_stream_template('alert', message),
79
+ content_type: 'text/vnd.turbo-stream.html',
80
+ status: status
81
+ end
82
+
83
+ def turbo_stream_template(type, message)
84
+ escaped = ERB::Util.html_escape(message)
85
+ '<turbo-stream action="append" target="notifications"><template>' \
86
+ "<div class=\"undo-notification undo-#{type}\">#{escaped}</div>" \
87
+ '</template></turbo-stream>'
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class LogsController < ApplicationController
6
+ before_action :set_undo_log
7
+ before_action :check_not_found
8
+ before_action :check_expired
9
+
10
+ def restore
11
+ return unless @undo_log
12
+
13
+ count = @undo_log.undo_log_items.count
14
+ execute_restore_action
15
+ respond_with_restored_log(count)
16
+ end
17
+
18
+ private
19
+
20
+ def set_undo_log
21
+ @undo_log = find_undo_log
22
+ end
23
+
24
+ def check_not_found
25
+ handle_not_found if @undo_log.nil?
26
+ end
27
+
28
+ def check_expired
29
+ handle_expired if @undo_log&.expired?
30
+ end
31
+
32
+ def find_undo_log
33
+ if params[:token].present?
34
+ ActiveRecord::Undo::UndoLog.find_by_signed_token(params[:token])
35
+ elsif params[:id].present?
36
+ find_by_id_or_token(params[:id])
37
+ end
38
+ end
39
+
40
+ def find_by_id_or_token(identifier)
41
+ str = identifier.to_s
42
+ return nil if str.blank?
43
+
44
+ if str.match?(/\A\d+\z/) || uuid_identifier?(str)
45
+ ActiveRecord::Undo::UndoLog.find_by(id: identifier)
46
+ else
47
+ ActiveRecord::Undo::UndoLog.find_by_signed_token(str)
48
+ end
49
+ end
50
+
51
+ def uuid_identifier?(str)
52
+ str.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i)
53
+ end
54
+
55
+ def execute_restore_action
56
+ tenant = resolve_current_tenant
57
+ with_tenant_context(tenant) do
58
+ @undo_log.restore!(whodunnit: resolve_whodunnit_user)
59
+ end
60
+ end
61
+
62
+ def with_tenant_context(tenant)
63
+ orig = ActiveRecord::Undo.current_tenant
64
+ ActiveRecord::Undo.current_tenant = tenant if tenant.present?
65
+ yield
66
+ ensure
67
+ ActiveRecord::Undo.current_tenant = orig
68
+ end
69
+
70
+ def respond_with_restored_log(count)
71
+ respond_to do |format|
72
+ format.html do
73
+ redirect_to determine_redirect_path, notice: 'Record successfully restored.', status: :see_other
74
+ end
75
+ format.turbo_stream { render_turbo_stream_success }
76
+ format.json { render json: { success: true, restored_items_count: count }, status: :ok }
77
+ end
78
+ end
79
+
80
+ def render_turbo_stream_success
81
+ render inline: turbo_stream_template('notice', 'Record successfully restored.'),
82
+ content_type: 'text/vnd.turbo-stream.html',
83
+ status: :ok
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ class RestoresController < LogsController
6
+ def create
7
+ restore
8
+ end
9
+ end
10
+ end
11
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ ActiveRecord::Undo::Engine.routes.draw do
4
+ resources :logs, only: [] do
5
+ member do
6
+ post :restore
7
+ end
8
+ end
9
+
10
+ post '/restore/:token', to: 'restores#create', as: :signed_restore
11
+ end
@@ -3,12 +3,19 @@
3
3
  module ActiveRecord
4
4
  module Undo
5
5
  class Configuration
6
- attr_accessor :retention_period, :current_user_method, :current_tenant_method
6
+ attr_accessor :retention_period, :current_user_method, :current_tenant_method,
7
+ :base_controller, :default_redirect_path, :token_secret_key,
8
+ :token_expires_in, :error_handling
7
9
 
8
10
  def initialize
9
11
  @retention_period = 30.days
10
12
  @current_user_method = nil
11
13
  @current_tenant_method = nil
14
+ @base_controller = '::ApplicationController'
15
+ @default_redirect_path = ->(main_app) { main_app.respond_to?(:root_path) ? main_app.root_path : '/' }
16
+ @token_secret_key = nil
17
+ @token_expires_in = 24.hours
18
+ @error_handling = :auto
12
19
  end
13
20
  end
14
21
  end
@@ -1,6 +1,7 @@
1
- # lib/active_record/undo/engine.rb
2
1
  # frozen_string_literal: true
3
2
 
3
+ require_relative 'view_helpers'
4
+
4
5
  module ActiveRecord
5
6
  module Undo
6
7
  class Engine < ::Rails::Engine
@@ -15,6 +16,18 @@ module ActiveRecord
15
16
  end
16
17
  end
17
18
 
19
+ initializer 'active_record_undo.view_helpers' do
20
+ ActiveSupport.on_load(:action_view) do
21
+ include ActiveRecord::Undo::ViewHelpers
22
+ end
23
+ end
24
+
25
+ initializer 'active_record_undo.turbo_stream_mime_type' do
26
+ if defined?(Mime::Type) && (!defined?(Mime[:turbo_stream]) || Mime[:turbo_stream].nil?)
27
+ Mime::Type.register 'text/vnd.turbo-stream.html', :turbo_stream
28
+ end
29
+ end
30
+
18
31
  rake_tasks do
19
32
  path = root.join('lib', 'tasks', 'active_record_undo_tasks.rake')
20
33
  load path if File.exist?(path)
@@ -40,6 +40,14 @@ module ActiveRecord
40
40
  attrs[:tenant_id] = ten
41
41
  end
42
42
  end
43
+
44
+ def with_whodunnit_context(actor)
45
+ orig = ActiveRecord::Undo.whodunnit
46
+ ActiveRecord::Undo.whodunnit = actor
47
+ yield
48
+ ensure
49
+ ActiveRecord::Undo.whodunnit = orig
50
+ end
43
51
  end
44
52
  end
45
53
  end
@@ -8,7 +8,7 @@ module ActiveRecord
8
8
 
9
9
  def verify_tenant_match!(undo_log)
10
10
  ctx = current_tenant_context
11
- raise_tenant_mismatch!(undo_log, 'nil') if ctx.nil?
11
+ raise_tenant_mismatch!(undo_log, 'nil') if ctx.blank?
12
12
 
13
13
  return if tenant_matches?(undo_log, ctx)
14
14
 
@@ -69,7 +69,7 @@ module ActiveRecord
69
69
  end
70
70
 
71
71
  def undoable?
72
- return false unless soft_deleted?
72
+ return false unless persisted? && soft_deleted?
73
73
 
74
74
  ActiveRecord::Undo::UndoLogItem.joins(:undo_log).exists?(
75
75
  item_type: self.class.name,
@@ -77,6 +77,18 @@ module ActiveRecord
77
77
  )
78
78
  end
79
79
 
80
+ def undo_log
81
+ return nil unless persisted? && soft_deleted?
82
+
83
+ find_latest_undo_log_item&.undo_log
84
+ end
85
+ alias latest_undo_log undo_log
86
+
87
+ def signed_token(expires_in: ActiveRecord::Undo.config.token_expires_in, purpose: :restore)
88
+ undo_log&.signed_token(expires_in: expires_in, purpose: purpose)
89
+ end
90
+ alias to_signed_token signed_token
91
+
80
92
  def soft_delete!(whodunnit: nil, tenant: nil)
81
93
  ensure_undoable_column_exists!
82
94
  return false if soft_deleted?
@@ -113,8 +125,9 @@ module ActiveRecord
113
125
 
114
126
  def find_latest_undo_log_item
115
127
  ActiveRecord::Undo::UndoLogItem
128
+ .joins(:undo_log)
116
129
  .where(item: self)
117
- .order(created_at: :desc)
130
+ .order(created_at: :desc, id: :desc)
118
131
  .first
119
132
  end
120
133
 
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'uri'
4
+
5
+ module ActiveRecord
6
+ module Undo
7
+ module SafeRedirect
8
+ private
9
+
10
+ def determine_redirect_path
11
+ safe_redirect_path(params[:redirect_to]) ||
12
+ safe_redirect_path(request.referer) ||
13
+ resolve_fallback_path
14
+ end
15
+
16
+ def safe_redirect_path(path)
17
+ return nil if invalid_path_string?(path)
18
+
19
+ uri = URI.parse(path)
20
+ return path if valid_relative_path?(path, uri)
21
+ return path if valid_same_host_path?(uri)
22
+
23
+ nil
24
+ rescue URI::InvalidURIError
25
+ nil
26
+ end
27
+
28
+ def invalid_path_string?(path)
29
+ path.blank? || !path.is_a?(String) || path.match?(/[\r\n]/)
30
+ end
31
+
32
+ def valid_relative_path?(path, uri)
33
+ uri.relative? && uri.scheme.nil? && path.start_with?('/') && !path.start_with?('//') && !path.start_with?('/\\')
34
+ end
35
+
36
+ def valid_same_host_path?(uri)
37
+ %w[http https].include?(uri.scheme) &&
38
+ uri.host == request.host &&
39
+ (uri.port.nil? || uri.port == request.port)
40
+ end
41
+
42
+ def resolve_fallback_path
43
+ cfg = ActiveRecord::Undo.config.default_redirect_path
44
+ if cfg.respond_to?(:call)
45
+ cfg.arity.zero? ? cfg.call : cfg.call(main_app)
46
+ elsif cfg.present?
47
+ cfg.to_s
48
+ else
49
+ default_root_path
50
+ end
51
+ rescue StandardError
52
+ '/'
53
+ end
54
+
55
+ def default_root_path
56
+ main_app.respond_to?(:root_path) ? main_app.root_path : '/'
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,11 +1,15 @@
1
- # lib/active_record/undo/undo_log.rb
2
1
  # frozen_string_literal: true
3
2
 
3
+ require 'active_support/message_verifier'
4
+
4
5
  module ActiveRecord
5
6
  module Undo
6
7
  class UndoLog < ActiveRecord::Base
7
8
  self.table_name = 'undo_logs'
8
9
 
10
+ include ModelExtension::AttributionHelper
11
+ include ModelExtension::TenantVerification
12
+
9
13
  has_many :undo_log_items, class_name: 'ActiveRecord::Undo::UndoLogItem', dependent: :destroy
10
14
 
11
15
  belongs_to :whodunnit, polymorphic: true, optional: true
@@ -36,10 +40,64 @@ module ActiveRecord
36
40
  end
37
41
  }
38
42
 
43
+ def self.find_by_signed_token(token, purpose: :restore)
44
+ return nil unless token.is_a?(String) && token.present?
45
+
46
+ verified_id = verify_signed_token(token, purpose: purpose)
47
+ return nil unless verified_id
48
+
49
+ find_by(id: verified_id)
50
+ rescue StandardError
51
+ nil
52
+ end
53
+
54
+ def self.verify_signed_token(token, purpose: :restore)
55
+ return nil unless token.is_a?(String) && token.present?
56
+
57
+ token_verifier.verified(token, purpose: purpose)
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def self.token_verifier
63
+ if defined?(Rails.application) && Rails.application.respond_to?(:message_verifier)
64
+ Rails.application.message_verifier(:active_record_undo)
65
+ else
66
+ key = ActiveRecord::Undo.config.token_secret_key || 'active_record_undo_default_secret_key_32_bytes'
67
+ @token_verifier ||= ActiveSupport::MessageVerifier.new(key, digest: 'SHA256')
68
+ end
69
+ end
70
+
71
+ def expired?
72
+ period = ActiveRecord::Undo.config.retention_period
73
+ return false unless period && created_at
74
+
75
+ created_at < Time.current - period
76
+ end
77
+
78
+ def signed_token(expires_in: ActiveRecord::Undo.config.token_expires_in, purpose: :restore)
79
+ self.class.token_verifier.generate(id, purpose: purpose, expires_in: expires_in)
80
+ end
81
+ alias to_signed_token signed_token
82
+
39
83
  # Restores all records associated with this deletion batch
40
- def restore!
84
+ def restore!(whodunnit: nil)
41
85
  ActiveRecord::Undo.verify_configured_context!
86
+ verify_tenant_match!(self) if tenant_present?
87
+
88
+ actor = resolve_whodunnit(whodunnit)
89
+ with_whodunnit_context(actor) do
90
+ execute_restore_transaction!
91
+ end
92
+ end
93
+
94
+ private
95
+
96
+ def tenant_present?
97
+ tenant_id.present? || tenant_type.present?
98
+ end
42
99
 
100
+ def execute_restore_transaction!
43
101
  transaction do
44
102
  # Reverse order ensures child records are restored before or after parents as needed
45
103
  undo_log_items.reverse_each(&:restore_item!)
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveRecord
5
5
  module Undo
6
- VERSION = '0.1.5'
6
+ VERSION = '0.2.0'
7
7
  end
8
8
  end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActiveRecord
4
+ module Undo
5
+ module ViewHelpers
6
+ def undo_button_to(target, text = nil, signed: false, **html_options)
7
+ btn_text = extract_undo_text(text, html_options)
8
+ url = resolve_undo_path(target, signed: signed)
9
+ button_to(btn_text, url, method: :post, **html_options)
10
+ end
11
+
12
+ def undo_link_to(target, text = nil, signed: false, **html_options)
13
+ link_text = extract_undo_text(text, html_options)
14
+ url = resolve_undo_path(target, signed: signed)
15
+ data = { turbo_method: :post }.merge(html_options.delete(:data) || {})
16
+ link_to(link_text, url, method: :post, data: data, **html_options)
17
+ end
18
+
19
+ private
20
+
21
+ def extract_undo_text(text, html_options)
22
+ text || html_options.delete(:text) || 'Undo'
23
+ end
24
+
25
+ def resolve_undo_path(target, signed: false)
26
+ router = undo_engine_router
27
+ if signed
28
+ token = extract_signed_token(target)
29
+ router.signed_restore_path(token: token)
30
+ else
31
+ router.restore_log_path(extract_log_target(target))
32
+ end
33
+ end
34
+
35
+ def extract_signed_token(target)
36
+ return target if target.is_a?(String)
37
+
38
+ token = token_from_target(target)
39
+ token || (raise ArgumentError, "Cannot generate signed token for #{target.inspect}")
40
+ end
41
+
42
+ def token_from_target(target)
43
+ if target.respond_to?(:signed_token)
44
+ target.signed_token
45
+ elsif target.respond_to?(:undo_log)
46
+ target.undo_log&.signed_token
47
+ else
48
+ find_log_for_model(target)&.signed_token
49
+ end
50
+ end
51
+
52
+ def extract_log_target(target)
53
+ return target if target.is_a?(ActiveRecord::Undo::UndoLog)
54
+
55
+ if model_target?(target)
56
+ log = log_for_target(target)
57
+ return log if log
58
+
59
+ raise ArgumentError, "Cannot resolve undo log for #{target.inspect}. " \
60
+ 'Ensure the record is soft-deleted and undoable.'
61
+ end
62
+
63
+ target
64
+ end
65
+
66
+ def model_target?(target)
67
+ target.respond_to?(:undoable?) || target.respond_to?(:undo_log)
68
+ end
69
+
70
+ def log_for_target(target)
71
+ if target.respond_to?(:undo_log)
72
+ target.undo_log
73
+ else
74
+ find_log_for_model(target)
75
+ end
76
+ end
77
+
78
+ def find_log_for_model(model)
79
+ return nil unless model.respond_to?(:undoable?)
80
+
81
+ ActiveRecord::Undo::UndoLogItem
82
+ .joins(:undo_log)
83
+ .where(item: model)
84
+ .order(created_at: :desc, id: :desc)
85
+ .first&.undo_log
86
+ end
87
+
88
+ def undo_engine_router
89
+ if respond_to?(:active_record_undo)
90
+ active_record_undo
91
+ elsif defined?(main_app) && main_app.respond_to?(:active_record_undo)
92
+ main_app.active_record_undo
93
+ elsif respond_to?(:restore_log_path)
94
+ self
95
+ else
96
+ ActiveRecord::Undo::Engine.routes.url_helpers
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -12,7 +12,9 @@ end
12
12
  require 'set'
13
13
  require_relative 'undo/version'
14
14
  require_relative 'undo/engine' if defined?(Rails::Engine)
15
+ require_relative 'undo/view_helpers' if defined?(ActionView)
15
16
  require_relative 'undo/configuration'
17
+ require_relative 'undo/safe_redirect'
16
18
  require_relative 'undo/model_extension'
17
19
  require_relative 'undo/undo_log'
18
20
  require_relative 'undo/undo_log_item'
@@ -85,6 +87,17 @@ module ActiveRecord
85
87
  raise ActiveRecord::Undo::SecurityError, msg
86
88
  end
87
89
 
90
+ def base_controller_class
91
+ configured = config.base_controller
92
+ return configured if configured.is_a?(Class)
93
+
94
+ (configured.presence && configured.to_s.safe_constantize) || default_base_controller
95
+ end
96
+
97
+ def default_base_controller
98
+ defined?(ActionController::Base) ? ActionController::Base : Object
99
+ end
100
+
88
101
  private
89
102
 
90
103
  def eager_load_rails!
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record-undo
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Saurabh Sharma
@@ -136,6 +136,10 @@ files:
136
136
  - LICENSE.txt
137
137
  - README.md
138
138
  - Rakefile
139
+ - app/controllers/active_record/undo/application_controller.rb
140
+ - app/controllers/active_record/undo/logs_controller.rb
141
+ - app/controllers/active_record/undo/restores_controller.rb
142
+ - config/routes.rb
139
143
  - db/migrate/20260808000000_create_active_record_undo_tables.rb
140
144
  - db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb
141
145
  - lib/active_record/undo.rb
@@ -150,10 +154,12 @@ files:
150
154
  - lib/active_record/undo/purge_job.rb
151
155
  - lib/active_record/undo/purger.rb
152
156
  - lib/active_record/undo/purger/reflection_helper.rb
157
+ - lib/active_record/undo/safe_redirect.rb
153
158
  - lib/active_record/undo/tasks/purge.rake
154
159
  - lib/active_record/undo/undo_log.rb
155
160
  - lib/active_record/undo/undo_log_item.rb
156
161
  - lib/active_record/undo/version.rb
162
+ - lib/active_record/undo/view_helpers.rb
157
163
  - lib/tasks/active_record_undo_tasks.rake
158
164
  - sig/active_record/undo.rbs
159
165
  homepage: https://github.com/saurabh-activecode/active_record-undo