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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +126 -0
- data/README.md +157 -3
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +1 -1
- data/app/helpers/studio/site_footer_helper.rb +18 -10
- data/app/views/registrations/new.html.erb +1 -1
- data/app/views/sessions/_sso_continue.html.erb +1 -1
- data/app/views/sessions/new.html.erb +6 -3
- data/app/views/studio/_confirm_interstitial.html.erb +1 -1
- data/app/views/studio/booking/_assets.html.erb +43 -9
- data/app/views/studio/booking/_frame.html.erb +12 -9
- data/app/views/studio/bookings/show.html.erb +4 -2
- data/app/views/studio/site_footer/_assets.html.erb +33 -10
- data/app/views/studio/site_footer/_footer.html.erb +4 -3
- data/app/views/studio/site_footer/_link.html.erb +3 -1
- data/app/views/style/_site_footer.html.erb +35 -12
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/booking.rb +164 -4
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/site_footer.rb +63 -7
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +45 -5
- data/lib/tasks/studio_trash.rake +83 -0
- metadata +6 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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"
|
data/lib/studio/site_footer.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ]
|
|
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
|
-
|
|
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
|
|
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 =
|
data/lib/studio/version.rb
CHANGED
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
|
|
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
|
-
|
|
1144
|
-
|
|
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.
|
|
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-
|
|
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
|