studio-engine 0.102.0.rc1 → 0.103.0.rc1
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 +51 -0
- data/README.md +27 -0
- data/app/models/image_cache.rb +50 -1
- data/db/migrate/20261010120000_add_stored_in_to_image_caches.rb +25 -0
- data/lib/studio/image_cache.rb +94 -8
- data/lib/studio/s3.rb +84 -5
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +13 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 773214119ffedd307e2f80ba808f5413cc69a2e9cb875c2997d7f7be0f120ce8
|
|
4
|
+
data.tar.gz: 9b6c272fd88d22717b6e69020146c936d8db1d04b88f5a9b3effe5764d64ec38
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fd521d84d03c3c7a45e0fa1113d065db78a67ef5de36a8ac66b237b28b15a9cd562f8e703436b75f6419e927243d13ca99877f9d3493a59a6bf276b3b07cb269
|
|
7
|
+
data.tar.gz: '0508739a475558e609a1efc465d75f2f37aa71fcf9c3b7477a759d3a900b4d1353628a150d8304f7a7b6bb568958cc97e1c8943382e57733a6466c8b803157f2'
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,57 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.103.0 — 2026-10-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Seed assets reference production.** A non-production process (laptop,
|
|
12
|
+
desk, QA app) can record an `ImageCache` row that REFERENCES an object
|
|
13
|
+
already in the production bucket instead of uploading a copy into the dev
|
|
14
|
+
bucket. New:
|
|
15
|
+
- `Studio.s3_production_public_url`: production's public host, set in
|
|
16
|
+
every environment (e.g. `https://assets.turfmonster.media`). Default nil
|
|
17
|
+
turns references off.
|
|
18
|
+
- `image_caches.stored_in` (migration `AddStoredInToImageCaches`):
|
|
19
|
+
nullable string. nil or `"own"` is the own bucket, `"production"` a
|
|
20
|
+
reference. `ImageCache#url` resolves a reference on the production host
|
|
21
|
+
off production. In production both kinds resolve on the own host, which
|
|
22
|
+
is production's.
|
|
23
|
+
- `Studio::ImageCache.reference_or_cache!`: `cache!`'s arguments and
|
|
24
|
+
answer. Off production, with the setting, it HEADs each missing variant
|
|
25
|
+
in `<prefix>-production` (read-only) and records a reference when found,
|
|
26
|
+
else uploads to the own bucket as `cache!` does. A HEAD failing for any
|
|
27
|
+
reason but not-found raises. In production, or without the setting, it
|
|
28
|
+
is `cache!`.
|
|
29
|
+
- `Studio::S3.production_bucket`, `production_references?`,
|
|
30
|
+
`production_url`, `production_head`, `production_exists?` and
|
|
31
|
+
`production_download`: reads only. `ImageCache#download` and
|
|
32
|
+
`#delete_object!` act on the bucket that holds the row's object;
|
|
33
|
+
off production `delete_object!` deletes nothing for a reference.
|
|
34
|
+
|
|
35
|
+
**Design.** A per-row marker plus one configured host, because a row must
|
|
36
|
+
say whose object it is: 11 of 12 sampled Turf headshot keys were on
|
|
37
|
+
production and one (`kenneth-walker-iii`) was only on dev, so no app-wide
|
|
38
|
+
switch is right for every row. Rejected: storing absolute URLs in `s3_key`
|
|
39
|
+
(breaks every reader that treats it as a key, the uniqueness index and the
|
|
40
|
+
key namespace); an AR enum (raises at load on a host that has not migrated);
|
|
41
|
+
an HTTP HEAD on the public host (a CDN answer is not the bucket's, and the
|
|
42
|
+
dev token's Item Read on production makes the S3 HEAD available and
|
|
43
|
+
stubbable); requiring the column in `Studio::HostSchema` (would fail boot on
|
|
44
|
+
every consumer before it installs the migration). The existing guard
|
|
45
|
+
(`guard_production_bucket!`) still refuses any delete from a `*-production`
|
|
46
|
+
bucket off production, and the dev R2 tokens cannot write production.
|
|
47
|
+
|
|
48
|
+
**Consumer adoption.** Nothing changes until an app opts in, and production
|
|
49
|
+
behaves as before. To adopt: (1) `bin/rails studio_engine:install:migrations
|
|
50
|
+
db:migrate` and commit the copy and `db/schema.rb`; (2) set
|
|
51
|
+
`config.s3_production_public_url` to the production asset host in
|
|
52
|
+
`config/initializers/studio.rb`; (3) switch seeders from
|
|
53
|
+
`Studio::ImageCache.cache!` to `reference_or_cache!`; (4) replace any
|
|
54
|
+
`Studio::S3.download(key: row.s3_key)` or `Studio::S3.delete(key:
|
|
55
|
+
row.s3_key)` on an `ImageCache` row with `row.download` /
|
|
56
|
+
`row.delete_object!`.
|
|
57
|
+
|
|
7
58
|
## 0.102.0 — 2026-10-10
|
|
8
59
|
|
|
9
60
|
### Removed
|
data/README.md
CHANGED
|
@@ -979,6 +979,33 @@ be a positive Integer.
|
|
|
979
979
|
`Studio::S3.delete` moves the object to `trash/` for three days rather than
|
|
980
980
|
deleting it; see [Trash and restore](#trash-and-restore).
|
|
981
981
|
|
|
982
|
+
### Seed assets reference production
|
|
983
|
+
|
|
984
|
+
Seed assets (NFL headshots, Pokémon sprites) live in the **production** bucket.
|
|
985
|
+
A laptop, desk or QA app points at production's copy instead of uploading a
|
|
986
|
+
duplicate into the dev bucket, and uploads only what production lacks. Set
|
|
987
|
+
production's public host in every environment (it is not a secret):
|
|
988
|
+
|
|
989
|
+
```ruby
|
|
990
|
+
config.s3_production_public_url = "https://assets.turfmonster.media"
|
|
991
|
+
```
|
|
992
|
+
|
|
993
|
+
and have seeders call `Studio::ImageCache.reference_or_cache!` (same arguments
|
|
994
|
+
and answer as `cache!`). Off production it HEADs each missing variant in
|
|
995
|
+
`<prefix>-production` (read-only; the dev R2 token holds Item Read there).
|
|
996
|
+
Found: an `ImageCache` row with `stored_in = "production"`, whose `#url`
|
|
997
|
+
resolves on `s3_production_public_url`. Absent: an ordinary upload to the own
|
|
998
|
+
bucket. A HEAD failing for any reason but not-found raises. In production, or
|
|
999
|
+
with the setting unset, it is exactly `cache!`.
|
|
1000
|
+
|
|
1001
|
+
`stored_in` arrives by the engine migration `AddStoredInToImageCaches`
|
|
1002
|
+
(nullable; nil and `"own"` mean the own bucket, so existing rows keep their
|
|
1003
|
+
URLs). Read an object's bytes with `ImageCache#download` and remove it with
|
|
1004
|
+
`ImageCache#delete_object!`, never `Studio::S3.download/delete(key: row.s3_key)`:
|
|
1005
|
+
those read and delete the OWN bucket. Off production, `delete_object!` on a
|
|
1006
|
+
production reference deletes nothing, and `Studio::S3` has no write, copy or
|
|
1007
|
+
delete that targets the production bucket.
|
|
1008
|
+
|
|
982
1009
|
An app with **no** bucket configured does not error — `/admin/emails` renders
|
|
983
1010
|
read-only, showing the inherited defaults it is genuinely sending and naming the
|
|
984
1011
|
one setting that turns uploads on.
|
data/app/models/image_cache.rb
CHANGED
|
@@ -1,4 +1,20 @@
|
|
|
1
1
|
class ImageCache < ApplicationRecord
|
|
2
|
+
# Where a row's object lives (the `stored_in` column):
|
|
3
|
+
#
|
|
4
|
+
# nil / "own" this process's own bucket, at Studio.s3_public_url. Every row
|
|
5
|
+
# written before the column existed, and every upload.
|
|
6
|
+
# "production" a REFERENCE to production's copy, recorded by a
|
|
7
|
+
# non-production seeder (Studio::ImageCache.reference_or_cache!)
|
|
8
|
+
# instead of uploading a duplicate. It resolves on
|
|
9
|
+
# Studio.s3_production_public_url.
|
|
10
|
+
#
|
|
11
|
+
# A plain string, not an AR enum: the column arrives by an engine migration a
|
|
12
|
+
# host installs on its own schedule, and an enum declared on an absent column
|
|
13
|
+
# raises at load. Without the column every row reads as "own", as before.
|
|
14
|
+
STORED_IN = %w[own production].freeze
|
|
15
|
+
OWN = "own"
|
|
16
|
+
PRODUCTION = "production"
|
|
17
|
+
|
|
2
18
|
# Optional so app-GLOBAL images (no owning record) can be cached too — e.g.
|
|
3
19
|
# Studio::EmailCatalog stores the admin-managed email banners owner-less. Per-
|
|
4
20
|
# record images (athlete/coach headshots) still set an owner; the
|
|
@@ -8,8 +24,41 @@ class ImageCache < ApplicationRecord
|
|
|
8
24
|
validates :purpose, :variant, :s3_key, presence: true
|
|
9
25
|
validates :s3_key, uniqueness: true
|
|
10
26
|
validates :variant, uniqueness: { scope: [:owner_type, :owner_id, :purpose] }
|
|
27
|
+
validates :stored_in, inclusion: { in: STORED_IN }, allow_nil: true, if: -> { has_attribute?(:stored_in) }
|
|
28
|
+
|
|
29
|
+
def self.stored_in_column?
|
|
30
|
+
table_exists? && column_names.include?("stored_in")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def production_reference?
|
|
34
|
+
has_attribute?(:stored_in) && stored_in == PRODUCTION
|
|
35
|
+
end
|
|
11
36
|
|
|
37
|
+
# In production both kinds resolve on the app's own host, which IS
|
|
38
|
+
# production's, so a production app's URLs are what they always were.
|
|
12
39
|
def url
|
|
13
|
-
Studio::S3.url(key: s3_key)
|
|
40
|
+
production_reference? ? Studio::S3.production_url(key: s3_key) : Studio::S3.url(key: s3_key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# The object's bytes, read from the bucket that holds them (read-only for a
|
|
44
|
+
# production reference). Use this, not Studio::S3.download(key: s3_key),
|
|
45
|
+
# which reads this process's own bucket.
|
|
46
|
+
def download(max_bytes: nil)
|
|
47
|
+
if production_reference?
|
|
48
|
+
Studio::S3.production_download(key: s3_key, max_bytes: max_bytes)
|
|
49
|
+
else
|
|
50
|
+
Studio::S3.download(key: s3_key, max_bytes: max_bytes)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Removes the row's OBJECT (to trash, via Studio::S3.delete). A production
|
|
55
|
+
# reference outside production is not this process's object: nothing is
|
|
56
|
+
# deleted from either bucket (the own bucket may hold an unrelated object
|
|
57
|
+
# under the same key) and nil is returned. Destroying the row itself is
|
|
58
|
+
# always fine. In production a reference is an ordinary own object.
|
|
59
|
+
def delete_object!
|
|
60
|
+
return nil if production_reference? && !Studio::S3.production_environment?
|
|
61
|
+
|
|
62
|
+
Studio::S3.delete(key: s3_key)
|
|
14
63
|
end
|
|
15
64
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Adds image_caches.stored_in: where a row's object lives. nil (every existing
|
|
2
|
+
# row) and "own" mean this app's own bucket; "production" marks a non-production
|
|
3
|
+
# row that REFERENCES production's copy of a seed asset instead of a duplicate
|
|
4
|
+
# in the dev bucket (Studio::ImageCache.reference_or_cache!, ImageCache#url).
|
|
5
|
+
#
|
|
6
|
+
# Nullable with no default, so the add is a catalog-only change on any table
|
|
7
|
+
# size and no existing row is rewritten or changes its URL.
|
|
8
|
+
#
|
|
9
|
+
# image_caches is host-owned; on a host without the table this is a no-op, like
|
|
10
|
+
# AllowNullImageCacheOwner. Studio::HostSchema does not require the column: a
|
|
11
|
+
# host that has not installed this migration keeps working, and every row reads
|
|
12
|
+
# as "own".
|
|
13
|
+
class AddStoredInToImageCaches < ActiveRecord::Migration[7.2]
|
|
14
|
+
def up
|
|
15
|
+
return unless table_exists?(:image_caches)
|
|
16
|
+
|
|
17
|
+
add_column :image_caches, :stored_in, :string, if_not_exists: true
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def down
|
|
21
|
+
return unless table_exists?(:image_caches)
|
|
22
|
+
|
|
23
|
+
remove_column :image_caches, :stored_in, if_exists: true
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/studio/image_cache.rb
CHANGED
|
@@ -46,21 +46,107 @@ module Studio
|
|
|
46
46
|
# Idempotent: variants already present in ImageCache are skipped. If
|
|
47
47
|
# nothing is missing, the source is never read.
|
|
48
48
|
def self.cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil, content_type: "image/png")
|
|
49
|
-
|
|
49
|
+
ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
|
|
52
|
+
missing = requested - existing.keys
|
|
53
|
+
return existing if missing.empty?
|
|
54
|
+
|
|
55
|
+
upload_variants!(existing, missing, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
|
|
56
|
+
source_url: source_url, source_path: source_path, content_type: content_type)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ── SEED ASSETS: REFERENCE PRODUCTION, UPLOAD ONLY WHAT IS NEW ────────────
|
|
60
|
+
#
|
|
61
|
+
# cache!'s signature and answer, for SEEDERS. Seed assets live in the
|
|
62
|
+
# production bucket; a laptop, desk or QA app should point at production's
|
|
63
|
+
# copy rather than upload a duplicate into the dev bucket. So, outside
|
|
64
|
+
# production, with Studio.s3_production_public_url set, each missing variant
|
|
65
|
+
# is first looked for in production by a read-only HEAD
|
|
66
|
+
# (Studio::S3.production_head) under the key cache! would write:
|
|
67
|
+
#
|
|
68
|
+
# found an ImageCache row with stored_in "production" is recorded. Its
|
|
69
|
+
# #url resolves on the production host. Nothing is uploaded.
|
|
70
|
+
# absent the variant is uploaded to this process's OWN bucket exactly as
|
|
71
|
+
# cache! does (stored_in nil = own). A key production lacks
|
|
72
|
+
# (kenneth-walker-iii, 2026-10-10) still gets an image.
|
|
73
|
+
#
|
|
74
|
+
# The source is fetched only when at least one variant has to be uploaded.
|
|
75
|
+
# A HEAD that fails for any reason but "not found" raises: a token without
|
|
76
|
+
# read on production must not quietly turn into a dev-bucket copy of
|
|
77
|
+
# everything.
|
|
78
|
+
#
|
|
79
|
+
# In production, or with s3_production_public_url unset, this IS cache!.
|
|
80
|
+
# Production never reads the knob, so production apps behave as before.
|
|
81
|
+
#
|
|
82
|
+
# Nothing here writes, copies or deletes in the production bucket.
|
|
83
|
+
def self.reference_or_cache!(owner:, purpose:, key_prefix:, widths:, source_url: nil, source_path: nil,
|
|
84
|
+
content_type: "image/png")
|
|
85
|
+
unless Studio::S3.production_references?
|
|
86
|
+
return cache!(owner: owner, purpose: purpose, key_prefix: key_prefix, widths: widths,
|
|
87
|
+
source_url: source_url, source_path: source_path, content_type: content_type)
|
|
53
88
|
end
|
|
54
89
|
|
|
55
|
-
|
|
90
|
+
unless ::ImageCache.stored_in_column?
|
|
91
|
+
raise MissingStoredInColumn,
|
|
92
|
+
"image_caches.stored_in is missing: run `bin/rails studio_engine:install:migrations db:migrate` " \
|
|
93
|
+
"before referencing production seed assets"
|
|
94
|
+
end
|
|
56
95
|
|
|
57
|
-
ext =
|
|
58
|
-
requested = ["original", *widths.map(&:to_s)]
|
|
96
|
+
ext, requested = prepare!(source_url: source_url, source_path: source_path, content_type: content_type, widths: widths)
|
|
59
97
|
|
|
60
98
|
existing = ::ImageCache.where(owner: owner, purpose: purpose).index_by(&:variant)
|
|
61
99
|
missing = requested - existing.keys
|
|
62
100
|
return existing if missing.empty?
|
|
63
101
|
|
|
102
|
+
to_upload = missing.reject do |variant|
|
|
103
|
+
s3_key = variant_key(key_prefix, variant, ext)
|
|
104
|
+
head = Studio::S3.production_head(key: s3_key)
|
|
105
|
+
next false if head.nil?
|
|
106
|
+
|
|
107
|
+
existing[variant] = ::ImageCache.create!(
|
|
108
|
+
owner: owner,
|
|
109
|
+
purpose: purpose,
|
|
110
|
+
variant: variant,
|
|
111
|
+
s3_key: s3_key,
|
|
112
|
+
stored_in: ::ImageCache::PRODUCTION,
|
|
113
|
+
source_url: source_url,
|
|
114
|
+
bytes: head.content_length,
|
|
115
|
+
content_type: head.content_type.to_s.empty? ? content_type : head.content_type
|
|
116
|
+
)
|
|
117
|
+
true
|
|
118
|
+
end
|
|
119
|
+
return existing if to_upload.empty?
|
|
120
|
+
|
|
121
|
+
upload_variants!(existing, to_upload, owner: owner, purpose: purpose, key_prefix: key_prefix, ext: ext,
|
|
122
|
+
source_url: source_url, source_path: source_path, content_type: content_type)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
class MissingStoredInColumn < StandardError; end
|
|
126
|
+
|
|
127
|
+
# The key every variant is written under, by cache! and looked up under by
|
|
128
|
+
# reference_or_cache!. One definition, so the two can never disagree.
|
|
129
|
+
def self.variant_key(key_prefix, variant, ext)
|
|
130
|
+
"#{key_prefix}/#{variant}.#{ext}"
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.prepare!(source_url:, source_path:, content_type:, widths:)
|
|
134
|
+
raise ArgumentError, "either source_url or source_path is required" if source_url.nil? && source_path.nil?
|
|
135
|
+
|
|
136
|
+
unless ALLOWED_CONTENT_TYPES.include?(content_type)
|
|
137
|
+
raise UnsupportedContentType, "content_type #{content_type.inspect} not in allowlist (#{ALLOWED_CONTENT_TYPES.join(", ")})"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
validate_source_url!(source_url) if source_url
|
|
141
|
+
|
|
142
|
+
[EXT_BY_TYPE[content_type], ["original", *widths.map(&:to_s)]]
|
|
143
|
+
end
|
|
144
|
+
private_class_method :prepare!
|
|
145
|
+
|
|
146
|
+
# Reads the source once and uploads each of `missing` to this process's own
|
|
147
|
+
# bucket, recording a row per variant into `existing`, which it returns.
|
|
148
|
+
def self.upload_variants!(existing, missing, owner:, purpose:, key_prefix:, ext:, source_url:, source_path:,
|
|
149
|
+
content_type:)
|
|
64
150
|
require "mini_magick"
|
|
65
151
|
|
|
66
152
|
body = if source_path
|
|
@@ -72,7 +158,6 @@ module Studio
|
|
|
72
158
|
missing.each do |variant|
|
|
73
159
|
if variant == "original"
|
|
74
160
|
payload = body
|
|
75
|
-
s3_key = "#{key_prefix}/original.#{ext}"
|
|
76
161
|
else
|
|
77
162
|
img = MiniMagick::Image.read(body)
|
|
78
163
|
# Cap ImageMagick resources per-invocation to prevent decompression-bomb DoS.
|
|
@@ -84,8 +169,8 @@ module Studio
|
|
|
84
169
|
c.resize "#{variant}x"
|
|
85
170
|
end
|
|
86
171
|
payload = img.to_blob
|
|
87
|
-
s3_key = "#{key_prefix}/#{variant}.#{ext}"
|
|
88
172
|
end
|
|
173
|
+
s3_key = variant_key(key_prefix, variant, ext)
|
|
89
174
|
|
|
90
175
|
Studio::S3.upload(
|
|
91
176
|
key: s3_key,
|
|
@@ -107,6 +192,7 @@ module Studio
|
|
|
107
192
|
|
|
108
193
|
existing
|
|
109
194
|
end
|
|
195
|
+
private_class_method :upload_variants!
|
|
110
196
|
|
|
111
197
|
# ── THE SSRF GUARD ────────────────────────────────────────────────────────
|
|
112
198
|
#
|
data/lib/studio/s3.rb
CHANGED
|
@@ -25,13 +25,18 @@ module Studio
|
|
|
25
25
|
# is answered as the empty string it means. Tested against a stubbed
|
|
26
26
|
# client only: no live store, R2 included, has been asked.
|
|
27
27
|
def download(key:, max_bytes: nil)
|
|
28
|
+
read_object(bucket, key, max_bytes)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The GET behind download and production_download.
|
|
32
|
+
def read_object(target, key, max_bytes)
|
|
28
33
|
# 0 or a negative would build "bytes=0--1", which a store may read as
|
|
29
34
|
# no range at all and answer with the whole object.
|
|
30
35
|
unless max_bytes.nil? || (max_bytes.is_a?(Integer) && max_bytes.positive?)
|
|
31
36
|
raise ArgumentError, "max_bytes must be a positive Integer, got #{max_bytes.inspect}"
|
|
32
37
|
end
|
|
33
38
|
|
|
34
|
-
opts = { bucket:
|
|
39
|
+
opts = { bucket: target, key: full_key(key) }
|
|
35
40
|
opts[:range] = "bytes=0-#{max_bytes - 1}" if max_bytes
|
|
36
41
|
s3 = client
|
|
37
42
|
# The rescue names an SDK constant, so it wraps only what runs after
|
|
@@ -84,10 +89,70 @@ module Studio
|
|
|
84
89
|
end
|
|
85
90
|
|
|
86
91
|
def exists?(key:)
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
92
|
+
!head(bucket, key).nil?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# ── PRODUCTION REFERENCES (read-only) ─────────────────────────────────
|
|
96
|
+
#
|
|
97
|
+
# Seed assets live in the PRODUCTION bucket, and a non-production process
|
|
98
|
+
# references them there instead of uploading its own copy (ImageCache's
|
|
99
|
+
# stored_in = "production"). Everything below READS production and
|
|
100
|
+
# nothing writes it: there is no upload, copy or delete that takes the
|
|
101
|
+
# production bucket, and delete/purge! still refuse a "*-production"
|
|
102
|
+
# bucket off production (guard_production_bucket!). The dev R2 tokens
|
|
103
|
+
# hold Item Read on production and Item Write on dev only, so the store
|
|
104
|
+
# refuses a write even if code here ever tried.
|
|
105
|
+
|
|
106
|
+
# The bucket production writes: "#{s3_bucket_prefix}-production" in every
|
|
107
|
+
# environment. In real production it is `bucket`.
|
|
108
|
+
def production_bucket
|
|
109
|
+
prefix = Studio.s3_bucket_prefix
|
|
110
|
+
raise NotConfigured, "Studio.s3_bucket_prefix not set in config/initializers/studio.rb" if prefix.nil? || prefix.empty?
|
|
111
|
+
|
|
112
|
+
"#{prefix}-production"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Whether this process may record a production reference: it is not
|
|
116
|
+
# production itself (production's own uploads already ARE production's)
|
|
117
|
+
# and it knows production's public host (Studio.s3_production_public_url).
|
|
118
|
+
def production_references?
|
|
119
|
+
!production_environment? && !Studio.s3_production_public_url.to_s.empty?
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The public URL of production's copy of `key`. In production it is
|
|
123
|
+
# exactly url(key:). Elsewhere it is s3_production_public_url plus the
|
|
124
|
+
# key, under the same s3_key_prefix (the namespace is per app, not per
|
|
125
|
+
# environment); without that base it RAISES rather than answer with this
|
|
126
|
+
# environment's host, where the object does not exist.
|
|
127
|
+
def production_url(key:)
|
|
128
|
+
return url(key: key) if production_environment?
|
|
129
|
+
|
|
130
|
+
base = Studio.s3_production_public_url.to_s
|
|
131
|
+
if base.empty?
|
|
132
|
+
raise NotConfigured,
|
|
133
|
+
"Studio.s3_production_public_url not set: cannot resolve production's copy of #{key.inspect} " \
|
|
134
|
+
"from a #{environment} process"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
"#{base.chomp("/")}/#{full_key(key)}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# A read-only HEAD against the production bucket: the object's metadata
|
|
141
|
+
# (content_length, content_type) when production holds `key`, nil when it
|
|
142
|
+
# does not. Any other answer (403 from a token without Item Read on
|
|
143
|
+
# production, a network error) RAISES: a seeder that cannot ask must not
|
|
144
|
+
# read the silence as "absent" and quietly upload a copy.
|
|
145
|
+
def production_head(key:)
|
|
146
|
+
head(production_bucket, key)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def production_exists?(key:)
|
|
150
|
+
!production_head(key: key).nil?
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# A read-only GET of production's copy, with download's max_bytes rules.
|
|
154
|
+
def production_download(key:, max_bytes: nil)
|
|
155
|
+
read_object(production_bucket, key, max_bytes)
|
|
91
156
|
end
|
|
92
157
|
|
|
93
158
|
# A RECOVERABLE delete: the object moves under trash/ in the same bucket
|
|
@@ -235,8 +300,22 @@ module Studio
|
|
|
235
300
|
@client = nil
|
|
236
301
|
end
|
|
237
302
|
|
|
303
|
+
private :read_object
|
|
304
|
+
|
|
238
305
|
private
|
|
239
306
|
|
|
307
|
+
# HEAD of `key` in `target`: the response, or nil when the object is
|
|
308
|
+
# absent. `target` is resolved by the caller, so an unconfigured app
|
|
309
|
+
# raises NotConfigured before the rescue below names an SDK constant.
|
|
310
|
+
def head(target, key)
|
|
311
|
+
s3 = client
|
|
312
|
+
begin
|
|
313
|
+
s3.head_object(bucket: target, key: full_key(key))
|
|
314
|
+
rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
|
|
315
|
+
nil
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
240
319
|
# WHICH BUCKET HALF THIS APP OWNS. Rails.env alone cannot answer it: no QA app
|
|
241
320
|
# sets RAILS_ENV, so every QA app boots as `production` exactly like real
|
|
242
321
|
# production. QA_ENV is the signal that separates them, and it is the one
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -1079,6 +1079,19 @@ module Studio
|
|
|
1079
1079
|
mattr_accessor :s3_secret_access_key, default: nil
|
|
1080
1080
|
mattr_accessor :s3_public_url, default: nil
|
|
1081
1081
|
|
|
1082
|
+
# SEED ASSETS LIVE IN PRODUCTION. The public base of THIS app's PRODUCTION
|
|
1083
|
+
# bucket (https://assets.turfmonster.media), set in every environment. A
|
|
1084
|
+
# non-production process (a laptop, a desk, a QA app) whose seeders call
|
|
1085
|
+
# Studio::ImageCache.reference_or_cache! records an ImageCache row that
|
|
1086
|
+
# REFERENCES production's copy of an object instead of uploading its own, and
|
|
1087
|
+
# that row's #url resolves on this host. Not a secret, so a literal in
|
|
1088
|
+
# config/initializers/studio.rb is the expected form.
|
|
1089
|
+
#
|
|
1090
|
+
# nil (the default) turns references off: reference_or_cache! is then exactly
|
|
1091
|
+
# cache!, and every row resolves on s3_public_url as it always has. A real
|
|
1092
|
+
# production process ignores it, because its own bucket IS production.
|
|
1093
|
+
mattr_accessor :s3_production_public_url, default: nil
|
|
1094
|
+
|
|
1082
1095
|
# Knowledge layer — the S3-backed document store + /admin/knowledge browser
|
|
1083
1096
|
# (Studio::KnowledgeDoc). Routes are opt-in like every route surface.
|
|
1084
1097
|
# knowledge_agents is the roster of agent slugs the intake UI offers
|
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.103.0.rc1
|
|
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-11 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -713,6 +713,7 @@ files:
|
|
|
713
713
|
- db/migrate/20261006120002_ensure_theme_settings_table.rb
|
|
714
714
|
- db/migrate/20261006120003_ensure_image_caches_table.rb
|
|
715
715
|
- db/migrate/20261008120000_add_recording_to_studio_knowledge_docs.rb
|
|
716
|
+
- db/migrate/20261010120000_add_stored_in_to_image_caches.rb
|
|
716
717
|
- docs/BOOKING.md
|
|
717
718
|
- docs/SITE_FOOTER.md
|
|
718
719
|
- lib/active_storage/service/studio_trash_s3_service.rb
|