studio-engine 0.84.0 → 0.86.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,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "erb"
5
+ require "uri"
6
+ require_relative "../s3" unless defined?(Studio::S3::Error)
7
+
8
+ module Studio
9
+ module S3
10
+ # THE GRACE PERIOD ON A DELETE. R2 has no object versioning, so a delete is
11
+ # forever the moment it lands: a replaced profile picture, a purged
12
+ # attachment, a banner swapped in /admin/emails. Trash turns that delete into
13
+ # a MOVE inside the same bucket:
14
+ #
15
+ # avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
16
+ #
17
+ # and the bucket's lifecycle rule (set on the bucket, not here) expires
18
+ # everything under trash/ after three days. Until then the object can be put
19
+ # back with `rake studio:trash:restore[TRASH_KEY]`.
20
+ #
21
+ # THE ORDER IS THE SAFETY. Copy first, delete second, and a copy that fails
22
+ # RAISES before the delete is ever sent. A failure anywhere leaves the
23
+ # original where it was: a leaked object costs storage, a lost one costs a
24
+ # user's photo.
25
+ #
26
+ # Everything here works on REAL object keys (any Studio.s3_key_prefix
27
+ # already applied) and an explicit client + bucket, so Studio::S3 and the
28
+ # Active Storage service (ActiveStorage::Service::StudioTrashS3Service) share
29
+ # one implementation. trash/ always sits at the bucket ROOT, even for an app
30
+ # that lives under a key prefix in a shared bucket, so ONE lifecycle rule and
31
+ # ONE Cloudflare block cover every app in the bucket.
32
+ module Trash
33
+ PREFIX = "trash/"
34
+
35
+ # A single CopyObject copies at most 5 GiB (S3 and R2 alike). Larger
36
+ # objects need a multipart copy, which this does not do: it refuses
37
+ # instead, and leaves the object in place. Nothing in the fleet is near
38
+ # it (moms-app's audio is ~650 MB).
39
+ MAX_COPY_BYTES = 5 * 1024**3
40
+
41
+ # The metadata trash! writes. restore! strips exactly these back off, so a
42
+ # restored object carries the metadata it was uploaded with and no more.
43
+ TRASH_METADATA_KEYS = %w[
44
+ original-key deleted-at deleted-env
45
+ blob-content-type blob-byte-size blob-checksum blob-filename
46
+ ].freeze
47
+
48
+ # Header fields a REPLACE copy would otherwise drop.
49
+ CARRIED_HEADERS = %i[content_type cache_control content_disposition content_encoding content_language].freeze
50
+
51
+ class TooLarge < Studio::S3::Error; end
52
+ class ProductionBucketRefused < Studio::S3::Error; end
53
+ class NotTrash < Studio::S3::Error; end
54
+ class RestoreConflict < Studio::S3::Error; end
55
+
56
+ TRASH_KEY = %r{\Atrash/(\d{4}-\d{2}-\d{2})/(\d+)/(.+)\z}m
57
+
58
+ class << self
59
+ # Move bucket/key under trash/ and return the trash key. Returns nil,
60
+ # sending no copy and no delete, when the object is already gone: an
61
+ # S3 delete of a missing key succeeds, and so does this.
62
+ def trash!(client:, bucket:, key:, env:, now: Time.now.utc)
63
+ head = head(client, bucket, key)
64
+ return nil unless head
65
+
66
+ size = head.content_length.to_i
67
+ if size > MAX_COPY_BYTES
68
+ raise TooLarge, "#{bucket}/#{key} is #{size} bytes; a single CopyObject moves at most " \
69
+ "#{MAX_COPY_BYTES} (5 GiB), so it was NOT trashed and NOT deleted. " \
70
+ "Delete it with Studio::S3.purge! if you mean it, or copy it aside by hand first."
71
+ end
72
+
73
+ trash_key = key_for(key, now: now)
74
+ client.copy_object(
75
+ bucket: bucket,
76
+ copy_source: copy_source(bucket, key),
77
+ key: trash_key,
78
+ metadata_directive: "REPLACE",
79
+ metadata: trash_metadata(head, key: key, env: env, now: now),
80
+ **carried_headers(head)
81
+ )
82
+ # Reached only when the copy returned. A raise above never deletes.
83
+ client.delete_object(bucket: bucket, key: key)
84
+ trash_key
85
+ end
86
+
87
+ # trash/<UTC date>/<epoch ms>/<original key>. The date makes a day's
88
+ # deletes easy to browse; the milliseconds keep two deletes of the same
89
+ # key (a profile picture replaced twice in a day) from colliding.
90
+ def key_for(key, now: Time.now.utc)
91
+ now = now.utc
92
+ "#{PREFIX}#{now.strftime('%Y-%m-%d')}/#{(now.to_r * 1000).floor}/#{key}"
93
+ end
94
+
95
+ # The original key a trash key was moved from. It is read from the trash
96
+ # key's own path, not from metadata, so it answers even for an object
97
+ # whose metadata a hand copy dropped.
98
+ def original_key(trash_key)
99
+ match = TRASH_KEY.match(trash_key.to_s)
100
+ raise NotTrash, "#{trash_key.inspect} is not a trash key (trash/<date>/<ms>/<key>)" unless match
101
+
102
+ match[3]
103
+ end
104
+
105
+ def trash_key?(key)
106
+ key.to_s.start_with?(PREFIX)
107
+ end
108
+
109
+ # Every trash key whose original key is `key` (any key when nil),
110
+ # oldest first. Pages through the whole trash/ prefix: it is three
111
+ # days deep, so it stays small.
112
+ def list(client:, bucket:, key: nil)
113
+ keys = []
114
+ token = nil
115
+ loop do
116
+ params = { bucket: bucket, prefix: PREFIX }
117
+ params[:continuation_token] = token if token
118
+ resp = client.list_objects_v2(**params)
119
+ resp.contents.each do |object|
120
+ next unless TRASH_KEY.match?(object.key)
121
+ next if key && original_key(object.key) != key
122
+
123
+ keys << object
124
+ end
125
+ break unless resp.is_truncated
126
+
127
+ token = resp.next_continuation_token
128
+ end
129
+ keys.sort_by(&:key)
130
+ end
131
+
132
+ # Copy a trashed object back to its original key and return that key.
133
+ # The trash copy is left alone (the lifecycle rule expires it), so a
134
+ # restore can be repeated. Refuses to overwrite a live object unless
135
+ # overwrite: true, because the original key may already hold a newer
136
+ # upload. Restores the BYTES only: an Active Storage blob row is gone
137
+ # by the time its object is trashed; see blob_attributes.
138
+ def restore!(client:, bucket:, trash_key:, overwrite: false)
139
+ original = original_key(trash_key)
140
+ head = head(client, bucket, trash_key)
141
+ raise NotTrash, "#{bucket}/#{trash_key} does not exist (expired, or never trashed)" unless head
142
+ if !overwrite && head(client, bucket, original)
143
+ raise RestoreConflict, "#{bucket}/#{original} already exists; pass overwrite (FORCE=1) to replace it"
144
+ end
145
+
146
+ client.copy_object(
147
+ bucket: bucket,
148
+ copy_source: copy_source(bucket, trash_key),
149
+ key: original,
150
+ metadata_directive: "REPLACE",
151
+ metadata: (head.metadata || {}).reject { |name, _| TRASH_METADATA_KEYS.include?(name) },
152
+ **carried_headers(head)
153
+ )
154
+ original
155
+ end
156
+
157
+ # What an Active Storage blob row needs to be rebuilt around a restored
158
+ # key, as far as the trash copy can say. filename is nil unless the
159
+ # upload carried a Content-Disposition (Active Storage only sends one
160
+ # for attachment-disposition blobs); the caller supplies it otherwise.
161
+ def blob_attributes(client:, bucket:, trash_key:)
162
+ head = head(client, bucket, trash_key)
163
+ raise NotTrash, "#{bucket}/#{trash_key} does not exist" unless head
164
+
165
+ meta = head.metadata || {}
166
+ {
167
+ key: original_key(trash_key),
168
+ filename: decode(meta["blob-filename"]),
169
+ content_type: meta["blob-content-type"] || head.content_type,
170
+ byte_size: (meta["blob-byte-size"] || head.content_length).to_i,
171
+ checksum: meta["blob-checksum"]
172
+ }
173
+ end
174
+
175
+ # CopySource is "<bucket>/<url-encoded key>"; each path segment is
176
+ # escaped and the slashes kept, the form S3 and R2 both accept.
177
+ def copy_source(bucket, key)
178
+ require "aws-sdk-s3"
179
+ "#{bucket}/#{Seahorse::Util.uri_path_escape(key)}"
180
+ end
181
+
182
+ private
183
+
184
+ def head(client, bucket, key)
185
+ client.head_object(bucket: bucket, key: key)
186
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
187
+ nil
188
+ end
189
+
190
+ def carried_headers(head)
191
+ CARRIED_HEADERS.each_with_object({}) do |name, out|
192
+ value = head.public_send(name)
193
+ out[name] = value unless value.nil? || value.to_s.empty?
194
+ end
195
+ end
196
+
197
+ # The original object's own metadata first, then the trash record, so a
198
+ # stale "original-key" from an earlier round trip can never win.
199
+ def trash_metadata(head, key:, env:, now:)
200
+ meta = (head.metadata || {}).to_h.dup
201
+ meta["original-key"] = encode(key)
202
+ meta["deleted-at"] = now.utc.iso8601
203
+ meta["deleted-env"] = env.to_s
204
+ meta["blob-content-type"] = head.content_type.to_s unless head.content_type.to_s.empty?
205
+ meta["blob-byte-size"] = head.content_length.to_i.to_s
206
+ checksum = checksum_from_etag(head.etag)
207
+ meta["blob-checksum"] = checksum if checksum
208
+ filename = filename_from_disposition(head.content_disposition)
209
+ meta["blob-filename"] = encode(filename) if filename
210
+ meta
211
+ end
212
+
213
+ # A single-part upload's ETag is the hex MD5 of its bytes, and Active
214
+ # Storage's blob checksum is that MD5 in base64. A multipart ETag
215
+ # ("<hex>-<parts>") is not an MD5 of the object, so it yields nothing.
216
+ def checksum_from_etag(etag)
217
+ hex = etag.to_s.delete('"')
218
+ return nil unless hex.match?(/\A\h{32}\z/)
219
+
220
+ [[hex].pack("H*")].pack("m0")
221
+ end
222
+
223
+ def filename_from_disposition(disposition)
224
+ value = disposition.to_s
225
+ if (match = value.match(/filename\*=UTF-8''([^;]+)/i))
226
+ return URI.decode_uri_component(match[1])
227
+ end
228
+ match = value.match(/filename="([^"]*)"/i) || value.match(/filename=([^;]+)/i)
229
+ match && !match[1].strip.empty? ? match[1].strip : nil
230
+ end
231
+
232
+ # S3 metadata travels as HTTP headers, so a value must be ASCII. A key
233
+ # or filename that is not is stored percent-encoded behind "url:".
234
+ def encode(value)
235
+ value = value.to_s
236
+ value.ascii_only? ? value : "url:#{ERB::Util.url_encode(value)}"
237
+ end
238
+
239
+ def decode(value)
240
+ return nil if value.nil?
241
+ return URI.decode_uri_component(value.delete_prefix("url:")) if value.start_with?("url:")
242
+
243
+ value
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
data/lib/studio/s3.rb CHANGED
@@ -55,15 +55,66 @@ module Studio
55
55
  false
56
56
  end
57
57
 
58
+ # A RECOVERABLE delete: the object moves under trash/ in the same bucket
59
+ # (Studio::S3::Trash) and the bucket's lifecycle rule expires it after
60
+ # three days. Returns the trash key, or nil when there was nothing to
61
+ # move. Refused outright when a non-production process is pointed at a
62
+ # production bucket. purge! is the delete that cannot be undone.
58
63
  def delete(key:)
59
- client.delete_object(bucket: bucket, key: full_key(key))
64
+ target = bucket
65
+ guard_production_bucket!(target)
66
+ Trash.trash!(client: client, bucket: target, key: full_key(key), env: deletion_environment)
67
+ end
68
+
69
+ # The HARD delete, gone at once with no trash copy. For objects with no
70
+ # value after deletion (regenerable derivatives, a trash copy itself).
71
+ # The same production-bucket guard as delete.
72
+ def purge!(key:)
73
+ target = bucket
74
+ guard_production_bucket!(target)
75
+ client.delete_object(bucket: target, key: full_key(key))
60
76
  end
61
77
 
62
78
  # Returns LOGICAL keys (the app's key namespace stripped back off), so a
63
79
  # caller can feed any result straight back into download/delete/url.
64
- def list(prefix: nil, max: 1000)
80
+ # Trashed objects are deleted objects, so trash/ keys are left out unless
81
+ # include_trash: true. The filter runs after S3's max_keys, so a listing
82
+ # with trash in range can return fewer than max keys.
83
+ def list(prefix: nil, max: 1000, include_trash: false)
65
84
  resp = client.list_objects_v2(bucket: bucket, prefix: full_key(prefix), max_keys: max)
66
- resp.contents.map { |object| logical_key(object.key) }
85
+ keys = resp.contents.map(&:key)
86
+ keys = keys.reject { |key| Trash.trash_key?(key) } unless include_trash
87
+ keys.map { |key| logical_key(key) }
88
+ end
89
+
90
+ # Whether this process is real production, by the SAME resolution that
91
+ # picks the bucket half (QA_ENV first, then Rails.env). Public because the
92
+ # Active Storage trash service guards on it too.
93
+ def production_environment?
94
+ environment == "production"
95
+ end
96
+
97
+ # A non-production process (a laptop, a QA app, CI) may never delete from
98
+ # a bucket named "*-production", whoever handed it the bucket name or the
99
+ # key. Studio::S3 derives its bucket from the same environment, so here it
100
+ # is a backstop; for an Active Storage service, whose bucket comes from
101
+ # storage.yml, it is the guard.
102
+ def guard_production_bucket!(bucket_name)
103
+ return unless bucket_name.to_s.end_with?("-production")
104
+ return if production_environment?
105
+
106
+ raise Trash::ProductionBucketRefused,
107
+ "refusing to delete from #{bucket_name}: this process resolves to a non-production " \
108
+ "environment (#{deletion_environment}); only production deletes production objects"
109
+ end
110
+
111
+ # The environment a trash copy records in its deleted-env metadata:
112
+ # "qa" for a QA app (which runs Rails as production), else Rails.env.
113
+ def deletion_environment
114
+ return "qa" if EnvironmentBanner.qa_environment?
115
+ return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
116
+
117
+ "unknown"
67
118
  end
68
119
 
69
120
  def bucket
@@ -190,3 +241,5 @@ module Studio
190
241
  end
191
242
  end
192
243
  end
244
+
245
+ require_relative "s3/trash"
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.84.0"
2
+ VERSION = "0.86.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -1,3 +1,16 @@
1
+ # What this file and lib/studio/* call at REQUIRE time, required here rather
2
+ # than left to the host. `15.minutes` and `24.hours` below are evaluated the
3
+ # moment `require "studio"` runs, which is Bundler.require in the host's
4
+ # config/application.rb, BEFORE Rails' :load_active_support initializer pulls in
5
+ # any core extension. A host on `rails/all` happens to have them already (the
6
+ # ActiveRecord and ActiveJob railties load them); a host with no ActiveRecord
7
+ # does not, and its boot died on `Integer#minutes` until it required
8
+ # active_support/core_ext/integer/time itself. A gem requires what it uses.
9
+ # test/integration/footer_only_consumer_test.rb boots that host.
10
+ require "active_support"
11
+ require "active_support/core_ext/module/attribute_accessors"
12
+ require "active_support/core_ext/numeric/time"
13
+ require "active_support/core_ext/integer/time"
1
14
  require "studio/version"
2
15
  require "studio/log_rotation"
3
16
  require "studio/ip_locations"
@@ -17,6 +30,7 @@ require "studio/site_footer"
17
30
  require "studio/booking"
18
31
  require "studio/navbar_links"
19
32
  require "studio/navbar_identity"
33
+ require "studio/auth_labels"
20
34
  require "studio/profile_sections"
21
35
  require "studio/profile_image"
22
36
  require "studio/oauth_identity"
@@ -201,6 +215,7 @@ module Studio
201
215
  # The name the signed-in user nav prints, and the signed-out button's label.
202
216
  # Both defaults render the navbar byte-identical to before. The engine has no
203
217
  # I18n catalogue, so the label is plain config rather than a locale key.
218
+ # The label also words the engine's auth pages (lib/studio/auth_labels.rb).
204
219
  # Rules: lib/studio/navbar_identity.rb.
205
220
  #
206
221
  # config.navbar_user_name = :player_name # a method on the user
@@ -950,6 +965,25 @@ module Studio
950
965
  # that intentionally break the contract).
951
966
  mattr_accessor :validate_user_contract, default: true
952
967
 
968
+ # Does the host load ActiveRecord at all? True for every app that requires
969
+ # `rails/all` or `active_record/railtie` (all the engine's database-backed
970
+ # consumers); false for a FOOTER-ONLY consumer, an app with no database that
971
+ # takes the engine for its site footer and nothing else (docs/SITE_FOOTER.md,
972
+ # "An app with no database").
973
+ #
974
+ # The RAILTIE, not ActiveRecord::Base: the railtie is what an app chooses in
975
+ # config/application.rb, it is defined at require time (before any engine
976
+ # initializer runs), and asking for it autoloads nothing.
977
+ #
978
+ # Three things key off it, each only when it reads false (Studio::Engine):
979
+ # the user-contract check is skipped, the engine's ActiveRecord-dependent
980
+ # app/ roots are kept out of eager loading, and
981
+ # `studio_engine:install:migrations` is a no-op that exits 0. An app WITH
982
+ # ActiveRecord sees none of them change.
983
+ def self.active_record?
984
+ defined?(::ActiveRecord::Railtie) ? true : false
985
+ end
986
+
953
987
  # Only methods that consumers must explicitly define are checked here.
954
988
  # Column accessors (#email, #name, #role) are NOT validated because
955
989
  # ActiveRecord defines them lazily — they don't appear on `.instance_methods`
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Find and restore objects Studio::S3.delete or the StudioTrashS3 Active Storage
4
+ # service moved under trash/. The bucket's lifecycle rule expires trash/ after
5
+ # three days; after that there is nothing to restore.
6
+ #
7
+ # bin/rails "studio:trash:list" # everything in trash
8
+ # bin/rails "studio:trash:list[avatars/abc.png]" # one key's trash copies
9
+ # bin/rails "studio:trash:restore[trash/2026-10-01/1759302000123/avatars/abc.png]"
10
+ #
11
+ # The bucket is Studio::S3's. SERVICE=<storage.yml service name> reads an Active
12
+ # Storage service's bucket instead (e.g. SERVICE=r2_production). FORCE=1 lets a
13
+ # restore overwrite an object that already sits at the original key.
14
+ #
15
+ # Logic lives in Studio::S3::Trash (unit-tested); these tasks only resolve the
16
+ # bucket and print.
17
+ # Guarded like studio_email.rake and studio_ses.rake: an app that also loads
18
+ # this file must not register a second copy of each action.
19
+ unless Rake::Task.task_defined?("studio:trash:restore")
20
+ namespace :studio do
21
+ namespace :trash do
22
+ def studio_trash_target
23
+ name = ENV["SERVICE"].to_s.strip
24
+ if name.empty?
25
+ { client: Studio::S3.client, bucket: Studio::S3.bucket, key_prefix: Studio::S3.key_prefix }
26
+ else
27
+ service = ActiveStorage::Blob.services.fetch(name.to_sym)
28
+ { client: service.client.client, bucket: service.bucket.name, key_prefix: "" }
29
+ end
30
+ end
31
+
32
+ desc "List trashed objects, optionally only those deleted from KEY"
33
+ task :list, [:key] => :environment do |_task, args|
34
+ target = studio_trash_target
35
+ key = args[:key].to_s.strip
36
+ # A Studio::S3 caller thinks in LOGICAL keys; the trash records the real
37
+ # one. Match either, so the key you passed to delete finds its copy.
38
+ wanted = key.empty? ? nil : [key, "#{target[:key_prefix]}#{key}"].uniq
39
+ objects = Studio::S3::Trash.list(client: target[:client], bucket: target[:bucket])
40
+ objects = objects.select { |object| wanted.include?(Studio::S3::Trash.original_key(object.key)) } if wanted
41
+
42
+ puts "#{objects.size} trashed object(s) in #{target[:bucket]}#{" for #{key}" if wanted}"
43
+ objects.each do |object|
44
+ puts "#{object.key} #{object.size} bytes #{object.last_modified&.utc&.iso8601}"
45
+ end
46
+ end
47
+
48
+ desc "Copy TRASH_KEY back to its original key (FORCE=1 overwrites)"
49
+ task :restore, [:trash_key] => :environment do |_task, args|
50
+ trash_key = args[:trash_key].to_s.strip
51
+ abort "usage: studio:trash:restore[trash/<date>/<ms>/<key>]" if trash_key.empty?
52
+
53
+ target = studio_trash_target
54
+ overwrite = %w[1 true yes].include?(ENV["FORCE"].to_s.downcase)
55
+ begin
56
+ original = Studio::S3::Trash.restore!(client: target[:client], bucket: target[:bucket],
57
+ trash_key: trash_key, overwrite: overwrite)
58
+ attributes = Studio::S3::Trash.blob_attributes(client: target[:client], bucket: target[:bucket],
59
+ trash_key: trash_key)
60
+ rescue Studio::S3::Error => error
61
+ abort "studio:trash:restore: #{error.message}"
62
+ end
63
+
64
+ puts "Restored #{target[:bucket]}/#{original}"
65
+ puts
66
+ puts "If this was an Active Storage object, its blob row is gone. Recreate it and re-attach:"
67
+ puts
68
+ puts " blob = ActiveStorage::Blob.create!("
69
+ puts " key: #{attributes[:key].inspect},"
70
+ puts " filename: #{(attributes[:filename] || 'FILENAME').inspect},"
71
+ puts " content_type: #{attributes[:content_type].inspect},"
72
+ puts " byte_size: #{attributes[:byte_size]},"
73
+ puts " checksum: #{attributes[:checksum].inspect},"
74
+ puts " service_name: #{(ENV['SERVICE'].to_s.strip.empty? ? 'SERVICE_NAME' : ENV['SERVICE'].strip).inspect}"
75
+ puts " )"
76
+ puts " record.photo.attach(blob) # the record and attachment it belonged to"
77
+ puts
78
+ puts "A nil checksum means a multipart upload, whose ETag is not an MD5. Compute it"
79
+ puts "from the restored bytes: Digest::MD5.base64digest(<downloaded bytes>)."
80
+ end
81
+ end
82
+ end
83
+ end
@@ -22,7 +22,14 @@ Gem::Specification.new do |spec|
22
22
  # read by bin/e2e-executed-set-check and test/lib/e2e_lane_contract_test.rb. It is
23
23
  # the only thing under config/, and it has no meaning in a consuming app, so it is
24
24
  # excluded rather than shipped. (e2e/, bin/ and test/ were never in this list.)
25
- spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE"] - ["config/e2e_lane.yml"]
25
+ #
26
+ # docs/SITE_FOOTER.md and docs/BOOKING.md ship because consumers cite them from
27
+ # their own initializers ("docs/SITE_FOOTER.md is the contract"), and a
28
+ # citation should resolve in the installed gem, not only on GitHub. The rest of
29
+ # docs/ is the engine's own operating material. Guarded by
30
+ # test/lib/gemspec_ships_footer_docs_test.rb.
31
+ spec.files = Dir["lib/**/*", "app/**/*", "config/**/*", "db/**/*", "tailwind/**/*", "Gemfile", "studio-engine.gemspec", "README.md", "CHANGELOG.md", "LICENSE",
32
+ "docs/SITE_FOOTER.md", "docs/BOOKING.md"] - ["config/e2e_lane.yml"]
26
33
  spec.require_paths = ["lib"]
27
34
 
28
35
  spec.add_dependency "rails", ">= 7.2", "< 8.2"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.84.0
4
+ version: 0.86.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -541,6 +541,7 @@ files:
541
541
  - app/views/studio/site_footer/_footer.html.erb
542
542
  - app/views/studio/site_footer/_link.html.erb
543
543
  - app/views/studio/site_footer/_map.html.erb
544
+ - app/views/studio/site_footer/_map_assets.html.erb
544
545
  - app/views/studio/site_footer/_social_icon.html.erb
545
546
  - app/views/studio/site_identities/edit.html.erb
546
547
  - app/views/style/_host.html.erb
@@ -584,9 +585,13 @@ files:
584
585
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
585
586
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
586
587
  - db/migrate/20260930120000_create_studio_site_identities.rb
588
+ - docs/BOOKING.md
589
+ - docs/SITE_FOOTER.md
590
+ - lib/active_storage/service/studio_trash_s3_service.rb
587
591
  - lib/generators/studio/site_identity/site_identity_generator.rb
588
592
  - lib/studio-engine.rb
589
593
  - lib/studio.rb
594
+ - lib/studio/auth_labels.rb
590
595
  - lib/studio/booking.rb
591
596
  - lib/studio/cable.rb
592
597
  - lib/studio/color_scale.rb
@@ -617,6 +622,7 @@ files:
617
622
  - lib/studio/public_user.rb
618
623
  - lib/studio/redis.rb
619
624
  - lib/studio/s3.rb
625
+ - lib/studio/s3/trash.rb
620
626
  - lib/studio/session_fingerprint.rb
621
627
  - lib/studio/session_state.rb
622
628
  - lib/studio/sidebar_sections.rb
@@ -627,6 +633,7 @@ files:
627
633
  - lib/studio/version.rb
628
634
  - lib/tasks/studio_email.rake
629
635
  - lib/tasks/studio_ses.rake
636
+ - lib/tasks/studio_trash.rake
630
637
  - studio-engine.gemspec
631
638
  - tailwind/studio.tailwind.config.js
632
639
  homepage: https://github.com/McRitchie-Studio/studio-engine