studio-engine 0.83.0 → 0.85.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"
@@ -94,6 +94,16 @@ module Studio
94
94
  nil
95
95
  end
96
96
 
97
+ # True on the app's OWN booking page (Studio.booking_path), which keeps the
98
+ # footer for a signed-in viewer as the engine's page does. Matched by the
99
+ # request's path, because the app's controller could be named anything.
100
+ def booking_page?(view, booking_path)
101
+ return false if booking_path.nil?
102
+ return false unless view.respond_to?(:request) && view.request.respond_to?(:path)
103
+
104
+ Studio::Booking.same_path?(view.request.path, booking_path)
105
+ end
106
+
97
107
  def validate!(declared)
98
108
  return if declared.nil? || declared.is_a?(Hash) || declared.respond_to?(:call)
99
109
 
@@ -106,7 +116,7 @@ module Studio
106
116
  #
107
117
  # name: the site name the engine resolved (the site identity's title)
108
118
  # logo: the logo the engine resolved (the navbar logo), or nil
109
- # booking_path: the booking page's path when the engine draws it, or nil
119
+ # booking_path: the booking page's path (Studio.booking_path_for), or nil
110
120
  def resolve(declared, view, name: nil, logo: nil, booking_path: nil)
111
121
  raw = declared.respond_to?(:call) ? declared.call(view) : declared
112
122
  return nil if raw.nil?
@@ -149,8 +159,16 @@ module Studio
149
159
  # config.site_footer_visible = ->(view) {
150
160
  # Studio::SiteFooter.default_visible?(view) && !view.controller_path.start_with?("app/")
151
161
  # }
152
- def default_visible?(view, controllers: nil)
162
+ #
163
+ # `booking_path` is the app's own booking page (Studio.booking_path, resolved
164
+ # for this view); the engine's page is exempt by its controller instead.
165
+ def default_visible?(view, controllers: nil, booking_path: nil)
153
166
  controllers = Studio.site_footer_controllers if controllers.nil? && defined?(Studio.site_footer_controllers)
167
+ if booking_path.nil? && Studio.respond_to?(:booking_path) && Studio.booking_path
168
+ booking_path = Studio.booking_path_for(view)
169
+ end
170
+ return true if booking_page?(view, booking_path)
171
+
154
172
  visible?(
155
173
  logged_in: view.respond_to?(:logged_in?) && view.logged_in? ? true : false,
156
174
  controller_name: view.respond_to?(:controller_name) ? view.controller_name : nil,
@@ -235,12 +253,14 @@ module Studio
235
253
  unlinked: !text(url).nil? && safe_href(url, "social url").nil? }
236
254
  end
237
255
 
238
- # [ heading, links ] or { heading:, links: }.
256
+ # [ heading, links ], [ heading, links, { width: 1.5 } ] or { heading:,
257
+ # links:, width: }. `width` is an optional hint: this column's share of the
258
+ # row against the others' 1 (see `tracks`).
239
259
  def column(row, booking_path)
240
- heading, links =
260
+ heading, links, options =
241
261
  if row.respond_to?(:to_h) && !row.is_a?(Array)
242
262
  hash = symbolize(row)
243
- [hash[:heading] || hash[:title], hash[:links]]
263
+ [hash[:heading] || hash[:title], hash[:links], hash]
244
264
  else
245
265
  Array(row)
246
266
  end
@@ -248,12 +268,48 @@ module Studio
248
268
  links = Array(links).filter_map { |link_row| link(link_row, booking_path) }
249
269
  return nil if heading.nil? && links.empty?
250
270
 
251
- { heading: heading, links: links }
271
+ options = options.respond_to?(:to_h) && !options.is_a?(Array) ? symbolize(options) : {}
272
+ { heading: heading, links: links, width: column_width(options[:width]) }
273
+ end
274
+
275
+ # A column's width hint, or nil for the default share. Anything that is not a
276
+ # number between MIN_COLUMN_WIDTH and MAX_COLUMN_WIDTH is no hint.
277
+ MIN_COLUMN_WIDTH = 0.5
278
+ MAX_COLUMN_WIDTH = 4
279
+
280
+ def column_width(value)
281
+ return nil unless value.is_a?(Numeric) && value.respond_to?(:finite?) && value.finite?
282
+ return nil unless value >= MIN_COLUMN_WIDTH && value <= MAX_COLUMN_WIDTH
283
+
284
+ value.to_f
285
+ end
286
+
287
+ # True for a label that is an address rather than words: no space in it, and
288
+ # an "@", a "." or a "/". It is kept on one line (.ftr-link-solid).
289
+ def solid_label?(label)
290
+ string = label.to_s
291
+ !string.match?(/\s/) && string.match?(%r{[@./]})
292
+ end
293
+
294
+ # THE LINK COLUMNS' GRID TRACKS from 768px, one per column: its `width:`
295
+ # hint as an fr share, else FIRST_COLUMN_WIDTH for the first (the one that
296
+ # usually holds an email address) and 1 for the rest. nil when there are no
297
+ # columns, or more than MAX_ROW_COLUMNS: those wrap as equal tracks.
298
+ FIRST_COLUMN_WIDTH = 1.5
299
+ MAX_ROW_COLUMNS = 4
300
+
301
+ def tracks(columns)
302
+ columns = Array(columns)
303
+ return nil if columns.empty? || columns.size > MAX_ROW_COLUMNS
304
+
305
+ columns.each_with_index.map do |column, index|
306
+ "#{format('%g', column[:width] || (index.zero? ? FIRST_COLUMN_WIDTH : 1))}fr"
307
+ end.join(" ")
252
308
  end
253
309
 
254
310
  # [ label, href ], [ label, href, { booking: true } ] or { label:, href:,
255
311
  # booking: }. A nil href is a disabled label; an http(s) href opens in a new
256
- # tab; `booking: true`, or an href equal to the engine's booking page, opens
312
+ # tab; `booking: true`, or an href equal to the booking page's path, opens
257
313
  # the booking popup (the href stays as the fallback).
258
314
  def link(row, booking_path = nil)
259
315
  label, href, options =
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.83.0"
2
+ VERSION = "0.85.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -17,6 +17,7 @@ require "studio/site_footer"
17
17
  require "studio/booking"
18
18
  require "studio/navbar_links"
19
19
  require "studio/navbar_identity"
20
+ require "studio/auth_labels"
20
21
  require "studio/profile_sections"
21
22
  require "studio/profile_image"
22
23
  require "studio/oauth_identity"
@@ -201,6 +202,7 @@ module Studio
201
202
  # The name the signed-in user nav prints, and the signed-out button's label.
202
203
  # Both defaults render the navbar byte-identical to before. The engine has no
203
204
  # I18n catalogue, so the label is plain config rather than a locale key.
205
+ # The label also words the engine's auth pages (lib/studio/auth_labels.rb).
204
206
  # Rules: lib/studio/navbar_identity.rb.
205
207
  #
206
208
  # config.navbar_user_name = :player_name # a method on the user
@@ -565,7 +567,7 @@ module Studio
565
567
  # The public site's footer: brand and social profiles, link columns, an
566
568
  # optional Location band with a live map, and a legal line. And the booking
567
569
  # primitives: Google Calendar's appointment page inline, in a popup, and on a
568
- # page. See docs/SITE_FOOTER.md.
570
+ # page. See docs/SITE_FOOTER.md and docs/BOOKING.md.
569
571
 
570
572
  # The footer's facts: a Hash, or a callable receiving the view. nil (the
571
573
  # default) means this app has no footer, and `studio_site_footer` renders
@@ -590,7 +592,8 @@ module Studio
590
592
  # controller name ("landing") or path ("admin/reports"). A visitor gets the
591
593
  # footer on every page; a signed-in viewer gets it only here, because every
592
594
  # other signed-in page is a working surface (a board, a queue, an editor) and
593
- # stays full height. The engine's own booking page is always included.
595
+ # stays full height. The booking page (the engine's, or the app's own at
596
+ # Studio.booking_path) is always included.
594
597
  #
595
598
  # config.site_footer_controllers = %w[landing packages contact_submissions]
596
599
  mattr_accessor :site_footer_controllers, default: []
@@ -634,6 +637,44 @@ module Studio
634
637
  # config.draw_booking_routes = true
635
638
  mattr_accessor :draw_booking_routes, default: false
636
639
 
640
+ # WHERE THE APP'S OWN BOOKING PAGE LIVES, for an app that renders
641
+ # `studio_booking_frame` in a view of its own instead of drawing the engine's
642
+ # /schedule: a path ("/schedule"), or a callable receiving the view
643
+ # (`->(view) { view.schedule_path }`). nil (the default) leaves the engine's
644
+ # page, when drawn, as the booking page. With it set, the page is treated as
645
+ # the engine's own is: a footer link to it opens the popup, `studio_booking_link`
646
+ # falls back to it, and it keeps the footer for a signed-in viewer. It wins
647
+ # over the engine's page when both are declared. It is written into hrefs, so
648
+ # it must be a local path (one leading "/"); see Studio::Booking.local_path?.
649
+ #
650
+ # config.booking_path = "/schedule"
651
+ mattr_reader :booking_path, default: nil
652
+
653
+ def self.booking_path=(declared)
654
+ @@booking_path = Booking.normalize_path(declared)
655
+ end
656
+
657
+ # The booking page's path for a view: Studio.booking_path, else the engine's
658
+ # /schedule when it is drawn, else nil.
659
+ def self.booking_path_for(view)
660
+ Booking.path_for(booking_path, view, drawn: draw_booking_routes)
661
+ end
662
+
663
+ # THE FRAME'S CROP AT REST: nil or false (the default) shows Google's whole
664
+ # page; a Hash of the schedule's measured box crops the frame to the slot
665
+ # picker until it is used. The numbers belong to ONE schedule, so there is no
666
+ # default crop: measure with `bin/booking-crop-measure <booking url>` and paste
667
+ # what it prints. Keys and derivation: lib/studio/booking.rb.
668
+ #
669
+ # config.booking_crop = { top: 200, bottom: 600, frame_height: 720 }
670
+ mattr_reader :booking_crop, default: nil
671
+
672
+ def self.booking_crop=(declared)
673
+ Booking.validate_crop!(declared)
674
+ Booking.crop(declared) # reports a crop that cannot be one, at boot
675
+ @@booking_crop = declared || nil
676
+ end
677
+
637
678
  # THE APP'S IDENTITY COPY, resolved: { title:, description:, image_url: }.
638
679
  # The operator's saved value (Studio::SiteIdentity, edited at
639
680
  # /admin/link_preview) wins, then the drafted Studio.site_title /
@@ -1140,9 +1181,8 @@ module Studio
1140
1181
  def self.site_footer_for(view)
1141
1182
  return nil if site_footer.nil?
1142
1183
 
1143
- booking_path = draw_booking_routes && view.respond_to?(:studio_booking_path) ? view.studio_booking_path : nil
1144
- SiteFooter.resolve(site_footer, view,
1145
- name: site_identity[:title], logo: logo_for("Footer Logo"), booking_path: booking_path)
1184
+ SiteFooter.resolve(site_footer, view, name: site_identity[:title], logo: logo_for("Footer Logo"),
1185
+ booking_path: booking_path_for(view))
1146
1186
  end
1147
1187
 
1148
1188
  # Navbar links resolved for a view context (lib/studio/navbar_links.rb).
@@ -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
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.83.0
4
+ version: 0.85.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
@@ -584,9 +584,11 @@ files:
584
584
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
585
585
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
586
586
  - db/migrate/20260930120000_create_studio_site_identities.rb
587
+ - lib/active_storage/service/studio_trash_s3_service.rb
587
588
  - lib/generators/studio/site_identity/site_identity_generator.rb
588
589
  - lib/studio-engine.rb
589
590
  - lib/studio.rb
591
+ - lib/studio/auth_labels.rb
590
592
  - lib/studio/booking.rb
591
593
  - lib/studio/cable.rb
592
594
  - lib/studio/color_scale.rb
@@ -617,6 +619,7 @@ files:
617
619
  - lib/studio/public_user.rb
618
620
  - lib/studio/redis.rb
619
621
  - lib/studio/s3.rb
622
+ - lib/studio/s3/trash.rb
620
623
  - lib/studio/session_fingerprint.rb
621
624
  - lib/studio/session_state.rb
622
625
  - lib/studio/sidebar_sections.rb
@@ -627,6 +630,7 @@ files:
627
630
  - lib/studio/version.rb
628
631
  - lib/tasks/studio_email.rake
629
632
  - lib/tasks/studio_ses.rake
633
+ - lib/tasks/studio_trash.rake
630
634
  - studio-engine.gemspec
631
635
  - tailwind/studio.tailwind.config.js
632
636
  homepage: https://github.com/McRitchie-Studio/studio-engine