active_record-undo 0.1.4 → 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.
- checksums.yaml +4 -4
- data/DOCUMENTATION.md +226 -92
- data/README.md +402 -131
- data/app/controllers/active_record/undo/application_controller.rb +91 -0
- data/app/controllers/active_record/undo/logs_controller.rb +87 -0
- data/app/controllers/active_record/undo/restores_controller.rb +11 -0
- data/config/routes.rb +11 -0
- data/db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb +16 -0
- data/lib/active_record/undo/configuration.rb +10 -1
- data/lib/active_record/undo/engine.rb +14 -1
- data/lib/active_record/undo/model_extension/attribution_helper.rb +54 -0
- data/lib/active_record/undo/model_extension/tenant_verification.rb +39 -0
- data/lib/active_record/undo/model_extension.rb +61 -21
- data/lib/active_record/undo/purger/reflection_helper.rb +38 -0
- data/lib/active_record/undo/purger.rb +50 -30
- data/lib/active_record/undo/safe_redirect.rb +60 -0
- data/lib/active_record/undo/undo_log.rb +81 -2
- data/lib/active_record/undo/version.rb +1 -1
- data/lib/active_record/undo/view_helpers.rb +101 -0
- data/lib/active_record/undo.rb +53 -0
- metadata +11 -1
|
@@ -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,13 +1,36 @@
|
|
|
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
|
|
|
15
|
+
belongs_to :whodunnit, polymorphic: true, optional: true
|
|
16
|
+
belongs_to :tenant, polymorphic: true, optional: true
|
|
17
|
+
|
|
18
|
+
scope :for_whodunnit, lambda { |user|
|
|
19
|
+
if user.is_a?(ActiveRecord::Base)
|
|
20
|
+
where(whodunnit: user)
|
|
21
|
+
else
|
|
22
|
+
where(whodunnit_id: user)
|
|
23
|
+
end
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
scope :for_tenant, lambda { |tenant|
|
|
27
|
+
if tenant.is_a?(ActiveRecord::Base)
|
|
28
|
+
where(tenant: tenant)
|
|
29
|
+
else
|
|
30
|
+
where(tenant_id: tenant)
|
|
31
|
+
end
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
scope :expired, lambda {
|
|
12
35
|
period = ActiveRecord::Undo.config.retention_period
|
|
13
36
|
if period
|
|
@@ -17,8 +40,64 @@ module ActiveRecord
|
|
|
17
40
|
end
|
|
18
41
|
}
|
|
19
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
|
+
|
|
20
83
|
# Restores all records associated with this deletion batch
|
|
21
|
-
def restore!
|
|
84
|
+
def restore!(whodunnit: nil)
|
|
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
|
|
99
|
+
|
|
100
|
+
def execute_restore_transaction!
|
|
22
101
|
transaction do
|
|
23
102
|
# Reverse order ensures child records are restored before or after parents as needed
|
|
24
103
|
undo_log_items.reverse_each(&:restore_item!)
|
|
@@ -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
|
data/lib/active_record/undo.rb
CHANGED
|
@@ -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'
|
|
@@ -22,8 +24,25 @@ require_relative 'undo/purge_job'
|
|
|
22
24
|
module ActiveRecord
|
|
23
25
|
module Undo
|
|
24
26
|
class Error < StandardError; end
|
|
27
|
+
class SecurityError < Error; end
|
|
25
28
|
|
|
26
29
|
class << self
|
|
30
|
+
def whodunnit
|
|
31
|
+
Thread.current[:active_record_undo_whodunnit]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def whodunnit=(value)
|
|
35
|
+
Thread.current[:active_record_undo_whodunnit] = value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def current_tenant
|
|
39
|
+
Thread.current[:active_record_undo_current_tenant]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def current_tenant=(value)
|
|
43
|
+
Thread.current[:active_record_undo_current_tenant] = value
|
|
44
|
+
end
|
|
45
|
+
|
|
27
46
|
def config
|
|
28
47
|
@config ||= Configuration.new
|
|
29
48
|
end
|
|
@@ -45,6 +64,40 @@ module ActiveRecord
|
|
|
45
64
|
models.uniq
|
|
46
65
|
end
|
|
47
66
|
|
|
67
|
+
def verify_configured_context!
|
|
68
|
+
user_null = configured_method_nil?(config.current_user_method)
|
|
69
|
+
tenant_null = configured_method_nil?(config.current_tenant_method)
|
|
70
|
+
return unless user_null || tenant_null
|
|
71
|
+
|
|
72
|
+
raise_configured_context_error!(user_null, tenant_null)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def configured_method_nil?(method)
|
|
76
|
+
method.respond_to?(:call) && method.call.nil?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def raise_configured_context_error!(user_null, tenant_null)
|
|
80
|
+
msg = if user_null && tenant_null
|
|
81
|
+
'Configured current_user_method and current_tenant_method both returned nil.'
|
|
82
|
+
elsif user_null
|
|
83
|
+
'Configured current_user_method returned nil.'
|
|
84
|
+
else
|
|
85
|
+
'Configured current_tenant_method returned nil.'
|
|
86
|
+
end
|
|
87
|
+
raise ActiveRecord::Undo::SecurityError, msg
|
|
88
|
+
end
|
|
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
|
+
|
|
48
101
|
private
|
|
49
102
|
|
|
50
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.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Saurabh Sharma
|
|
@@ -136,7 +136,12 @@ 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
|
|
144
|
+
- db/migrate/20260821000000_add_tenant_and_user_attribution_to_undo_logs.rb
|
|
140
145
|
- lib/active_record/undo.rb
|
|
141
146
|
- lib/active_record/undo/cascade_handler.rb
|
|
142
147
|
- lib/active_record/undo/cascade_handler/association_finder.rb
|
|
@@ -144,12 +149,17 @@ files:
|
|
|
144
149
|
- lib/active_record/undo/configuration.rb
|
|
145
150
|
- lib/active_record/undo/engine.rb
|
|
146
151
|
- lib/active_record/undo/model_extension.rb
|
|
152
|
+
- lib/active_record/undo/model_extension/attribution_helper.rb
|
|
153
|
+
- lib/active_record/undo/model_extension/tenant_verification.rb
|
|
147
154
|
- lib/active_record/undo/purge_job.rb
|
|
148
155
|
- lib/active_record/undo/purger.rb
|
|
156
|
+
- lib/active_record/undo/purger/reflection_helper.rb
|
|
157
|
+
- lib/active_record/undo/safe_redirect.rb
|
|
149
158
|
- lib/active_record/undo/tasks/purge.rake
|
|
150
159
|
- lib/active_record/undo/undo_log.rb
|
|
151
160
|
- lib/active_record/undo/undo_log_item.rb
|
|
152
161
|
- lib/active_record/undo/version.rb
|
|
162
|
+
- lib/active_record/undo/view_helpers.rb
|
|
153
163
|
- lib/tasks/active_record_undo_tasks.rake
|
|
154
164
|
- sig/active_record/undo.rbs
|
|
155
165
|
homepage: https://github.com/saurabh-activecode/active_record-undo
|