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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -0
- data/README.md +162 -1
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +4 -2
- data/app/helpers/studio/site_footer_helper.rb +9 -0
- data/app/views/registrations/new.html.erb +59 -36
- 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/site_footer/_assets.html.erb +27 -159
- data/app/views/studio/site_footer/_map.html.erb +4 -4
- data/app/views/studio/site_footer/_map_assets.html.erb +157 -0
- data/docs/BOOKING.md +377 -0
- data/docs/SITE_FOOTER.md +375 -0
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/engine.rb +81 -2
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +34 -0
- data/lib/tasks/studio_trash.rake +83 -0
- data/studio-engine.gemspec +8 -1
- metadata +9 -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/version.rb
CHANGED
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
|
data/studio-engine.gemspec
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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-
|
|
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
|