Merge branch '22000-deleting-favorites' into main. Closes #22000
[arvados.git] / services / api / lib / trashable.rb
1 # Copyright (C) The Arvados Authors. All rights reserved.
2 #
3 # SPDX-License-Identifier: AGPL-3.0
4
5 module Trashable
6   def self.included(base)
7     base.before_validation :set_validation_timestamp
8     base.before_validation :ensure_trash_at_not_in_past
9     base.before_validation :sync_trash_state
10     base.before_validation :default_trash_interval
11     base.validate :validate_trash_and_delete_timing
12   end
13
14   # Use a single timestamp for all validations, even though each
15   # validation runs at a different time.
16   def set_validation_timestamp
17     @validation_timestamp = db_current_time
18   end
19
20   # If trash_at is being changed to a time in the past, change it to
21   # now. This allows clients to say "expires {client-current-time}"
22   # without failing due to clock skew, while avoiding odd log entries
23   # like "expiry date changed to {1 year ago}".
24   def ensure_trash_at_not_in_past
25     if trash_at_changed? && trash_at
26       self.trash_at = [@validation_timestamp, trash_at].max
27     end
28   end
29
30   # Caller can move into/out of trash by setting/clearing is_trashed
31   # -- however, if the caller also changes trash_at, then any changes
32   # to is_trashed are ignored.
33   def sync_trash_state
34     if is_trashed_changed? && !trash_at_changed?
35       if is_trashed
36         self.trash_at = @validation_timestamp
37       else
38         self.trash_at = nil
39         self.delete_at = nil
40       end
41     end
42     self.is_trashed = trash_at && trash_at <= @validation_timestamp || false
43     true
44   end
45
46   def default_delete_after_trash_interval
47     Rails.configuration.Collections.DefaultTrashLifetime
48   end
49
50   def minimum_delete_after_trash_interval
51     Rails.configuration.Collections.BlobSigningTTL
52   end
53
54   def default_trash_interval
55     if trash_at_changed? && !delete_at_changed?
56       # If trash_at is updated without touching delete_at,
57       # automatically update delete_at to a sensible value.
58       if trash_at.nil?
59         self.delete_at = nil
60       else
61         self.delete_at = trash_at + self.default_delete_after_trash_interval
62       end
63     elsif !trash_at || !delete_at || trash_at > delete_at
64       # Not trash, or bogus arguments? Just validate in
65       # validate_trash_and_delete_timing.
66     elsif delete_at_changed? && delete_at >= trash_at
67       # Fix delete_at if needed, so it's not earlier than the expiry
68       # time on any permission tokens that might have been given out.
69
70       # In any case there are no signatures expiring after now+TTL.
71       # Also, if the existing trash_at time has already passed, we
72       # know we haven't given out any signatures since then.
73       earliest_delete = [
74         @validation_timestamp,
75         trash_at_was,
76       ].compact.min + minimum_delete_after_trash_interval
77
78       # The previous value of delete_at is also an upper bound on the
79       # longest-lived permission token. For example, if TTL=14,
80       # trash_at_was=now-7, delete_at_was=now+7, then it is safe to
81       # set trash_at=now+6, delete_at=now+8.
82       earliest_delete = [earliest_delete, delete_at_was].compact.min
83
84       # If delete_at is too soon, use the earliest possible time.
85       if delete_at < earliest_delete
86         self.delete_at = earliest_delete
87       end
88     end
89   end
90
91   def validate_trash_and_delete_timing
92     if trash_at.nil? != delete_at.nil?
93       errors.add :delete_at, "must be set if trash_at is set, and must be nil otherwise"
94     elsif delete_at && delete_at < trash_at
95       errors.add :delete_at, "must not be earlier than trash_at"
96     end
97     true
98   end
99 end
100
101 module TrashableController
102   def self.included(base)
103     def base._trash_method_description
104       match = name.match(/\b(\w+)Controller$/)
105       "Trash a #{match[1].singularize.underscore.humanize.downcase}."
106     end
107     def base._untrash_method_description
108       match = name.match(/\b(\w+)Controller$/)
109       "Untrash a #{match[1].singularize.underscore.humanize.downcase}."
110     end
111   end
112
113   def destroy
114     if !@object.is_trashed
115       @object.update!(trash_at: db_current_time)
116     end
117     earliest_delete = (@object.trash_at + @object.minimum_delete_after_trash_interval)
118     if @object.delete_at > earliest_delete
119       @object.update!(delete_at: earliest_delete)
120     end
121     show
122   end
123
124   def trash
125     if !@object.is_trashed
126       @object.update!(trash_at: db_current_time)
127     end
128     show
129   end
130
131   def untrash
132     if !@object.is_trashed
133       raise ArvadosModel::InvalidStateTransitionError.new("Item is not trashed, cannot untrash")
134     end
135
136     if db_current_time >= @object.delete_at
137       raise ArvadosModel::InvalidStateTransitionError.new("delete_at time has already passed, cannot untrash")
138     end
139
140     @object.trash_at = nil
141
142     if params[:ensure_unique_name]
143       @object.save_with_unique_name!
144     else
145       @object.save!
146     end
147
148     show
149   end
150 end