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.
@@ -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!)
@@ -3,6 +3,6 @@
3
3
 
4
4
  module ActiveRecord
5
5
  module Undo
6
- VERSION = '0.1.4'
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'
@@ -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.1.4
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