studio-engine 0.102.0 → 0.103.0.rc2
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 +90 -0
- data/README.md +27 -0
- data/app/components/studio/badge_component.rb +4 -3
- data/app/models/image_cache.rb +50 -1
- data/app/views/components/_link_sidebar.html.erb +2 -2
- data/app/views/studio/emails/_row.html.erb +1 -1
- data/app/views/studio/emails/show.html.erb +1 -1
- data/app/views/style/_tasks.html.erb +1 -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/theme_resolver.rb +36 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +13 -0
- data/tailwind/studio.tailwind.config.js +3 -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: 792f0e16ad48572393e4e8cf22f51d497e832af869b0d1bfe6dd5be6ac8da058
|
|
4
|
+
data.tar.gz: c862691168c2c8e782d4b4ec7d6d956058494b717fd2d4b2757f6d9f2bd60148
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5d7b0b148093fca45e7ded99ad0056fc6a8a6e5dc698f1b85a4032414ae9a2a92b92ab94189ba3f7fc287b45fc40b1796e0b4807b910b08a99403b202d965c4a
|
|
7
|
+
data.tar.gz: 5c37ecd0f4c1f526cebe802cfc46a0dda47c4be3b1558ad57d2648de3fbfbf29fa122267551b5673f33000bf1edbaef0ba2a7154ab7f127c36fb099a14487015
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,96 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`--color-primary-ink` and `text-primary-ink`: the primary colour as chip
|
|
10
|
+
text.** The theme resolver derives it per theme the way it derives the
|
|
11
|
+
danger, warning and success inks: the configured primary, shaded until it
|
|
12
|
+
clears WCAG AA 4.5:1 on every surface and on its own 10%, 15% and 20% tints.
|
|
13
|
+
`text-primary` and `--color-primary` stay the brand colour.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- **Primary chips are painted in the primary colour and clear AA in both
|
|
18
|
+
themes.** `Studio::BadgeComponent`'s `tone: :primary` drew heading-coloured
|
|
19
|
+
text (white in dark, near-black in light), and its `scheme: "primary"`, the
|
|
20
|
+
Marketing chip on `/admin/emails` and the sidebar's ADMIN chip drew the raw
|
|
21
|
+
primary, 2.2:1 to 3.6:1 on its tint. All of them now draw `text-primary-ink`.
|
|
22
|
+
A host that copies the tone's class string (the hub's `status_tone`) changes
|
|
23
|
+
when it adopts the class.
|
|
24
|
+
- **Every role ink clears AA on its 10%, 15% and 20% tints, as a browser
|
|
25
|
+
paints them.** The apps put the inks on the stronger tints too (turf's
|
|
26
|
+
"Entered" badge is `bg-primary/15`; the hub's workflow and verdict chips are
|
|
27
|
+
`bg-success/20 text-success-ink` and `bg-warning/20 text-warning-ink`), where
|
|
28
|
+
an ink tuned to the 10% chip read as low as 3.83:1 (default warning, dark, on
|
|
29
|
+
20%). The search now counts 0.10, 26/255 (how Chromium composites a 10%
|
|
30
|
+
fill), 0.15 and 0.20, each also one unit off in every channel, because 8-bit
|
|
31
|
+
compositing rounds (Chromium painted a 20% danger tint one unit lighter than
|
|
32
|
+
the blend, taking a 4.51:1 ink to 4.47:1). The inks darken in light and
|
|
33
|
+
lighten in dark. Default theme, 0.102.0 to this release:
|
|
34
|
+
|
|
35
|
+
| Ink | Dark | Light |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| primary (new) | `#BDB7FE` | `#58519D` |
|
|
38
|
+
| success | `#8EB791` to `#96BC99` | `#2E6C32` to `#2A622D` |
|
|
39
|
+
| warning | `#FF966C` to `#FFAE8D` | `#994A2B` to `#8F4528` |
|
|
40
|
+
| danger | `#F58F8F` to `#F79E9E` | `#AC3131` to `#9E2D2D` |
|
|
41
|
+
|
|
42
|
+
A configured role colour moves the same way; a configured colour that
|
|
43
|
+
already clears every tint (mcritchie-industries' yellow warning) keeps its
|
|
44
|
+
exact hex.
|
|
45
|
+
|
|
46
|
+
## 0.103.0 — 2026-10-10
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **Seed assets reference production.** A non-production process (laptop,
|
|
51
|
+
desk, QA app) can record an `ImageCache` row that REFERENCES an object
|
|
52
|
+
already in the production bucket instead of uploading a copy into the dev
|
|
53
|
+
bucket. New:
|
|
54
|
+
- `Studio.s3_production_public_url`: production's public host, set in
|
|
55
|
+
every environment (e.g. `https://assets.turfmonster.media`). Default nil
|
|
56
|
+
turns references off.
|
|
57
|
+
- `image_caches.stored_in` (migration `AddStoredInToImageCaches`):
|
|
58
|
+
nullable string. nil or `"own"` is the own bucket, `"production"` a
|
|
59
|
+
reference. `ImageCache#url` resolves a reference on the production host
|
|
60
|
+
off production. In production both kinds resolve on the own host, which
|
|
61
|
+
is production's.
|
|
62
|
+
- `Studio::ImageCache.reference_or_cache!`: `cache!`'s arguments and
|
|
63
|
+
answer. Off production, with the setting, it HEADs each missing variant
|
|
64
|
+
in `<prefix>-production` (read-only) and records a reference when found,
|
|
65
|
+
else uploads to the own bucket as `cache!` does. A HEAD failing for any
|
|
66
|
+
reason but not-found raises. In production, or without the setting, it
|
|
67
|
+
is `cache!`.
|
|
68
|
+
- `Studio::S3.production_bucket`, `production_references?`,
|
|
69
|
+
`production_url`, `production_head`, `production_exists?` and
|
|
70
|
+
`production_download`: reads only. `ImageCache#download` and
|
|
71
|
+
`#delete_object!` act on the bucket that holds the row's object;
|
|
72
|
+
off production `delete_object!` deletes nothing for a reference.
|
|
73
|
+
|
|
74
|
+
**Design.** A per-row marker plus one configured host, because a row must
|
|
75
|
+
say whose object it is: 11 of 12 sampled Turf headshot keys were on
|
|
76
|
+
production and one (`kenneth-walker-iii`) was only on dev, so no app-wide
|
|
77
|
+
switch is right for every row. Rejected: storing absolute URLs in `s3_key`
|
|
78
|
+
(breaks every reader that treats it as a key, the uniqueness index and the
|
|
79
|
+
key namespace); an AR enum (raises at load on a host that has not migrated);
|
|
80
|
+
an HTTP HEAD on the public host (a CDN answer is not the bucket's, and the
|
|
81
|
+
dev token's Item Read on production makes the S3 HEAD available and
|
|
82
|
+
stubbable); requiring the column in `Studio::HostSchema` (would fail boot on
|
|
83
|
+
every consumer before it installs the migration). The existing guard
|
|
84
|
+
(`guard_production_bucket!`) still refuses any delete from a `*-production`
|
|
85
|
+
bucket off production, and the dev R2 tokens cannot write production.
|
|
86
|
+
|
|
87
|
+
**Consumer adoption.** Nothing changes until an app opts in, and production
|
|
88
|
+
behaves as before. To adopt: (1) `bin/rails studio_engine:install:migrations
|
|
89
|
+
db:migrate` and commit the copy and `db/schema.rb`; (2) set
|
|
90
|
+
`config.s3_production_public_url` to the production asset host in
|
|
91
|
+
`config/initializers/studio.rb`; (3) switch seeders from
|
|
92
|
+
`Studio::ImageCache.cache!` to `reference_or_cache!`; (4) replace any
|
|
93
|
+
`Studio::S3.download(key: row.s3_key)` or `Studio::S3.delete(key:
|
|
94
|
+
row.s3_key)` on an `ImageCache` row with `row.download` /
|
|
95
|
+
`row.delete_object!`.
|
|
96
|
+
|
|
7
97
|
## 0.102.0 — 2026-10-10
|
|
8
98
|
|
|
9
99
|
### 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.
|
|
@@ -20,7 +20,8 @@ module Studio
|
|
|
20
20
|
# rest of the page uses for that status. Tokens only, so it reads in both themes.
|
|
21
21
|
#
|
|
22
22
|
# scheme: is the palette the components/badge partial took (SCHEMES), including
|
|
23
|
-
# the stage-* pipeline ladder
|
|
23
|
+
# the stage-* pipeline ladder. Its "primary" draws the primary ink; the rest
|
|
24
|
+
# render exactly what that partial rendered.
|
|
24
25
|
# An unknown scheme renders FALLBACK, as the partial did.
|
|
25
26
|
#
|
|
26
27
|
# Pass tone: or scheme:, not both. Neither renders the neutral scheme.
|
|
@@ -34,7 +35,7 @@ module Studio
|
|
|
34
35
|
success: "bg-success/10 text-success-ink border-success/40",
|
|
35
36
|
warning: "bg-warning/10 text-warning-ink border-warning/40",
|
|
36
37
|
danger: "bg-danger/10 text-danger-ink border-danger/40",
|
|
37
|
-
primary: "bg-primary/10 text-
|
|
38
|
+
primary: "bg-primary/10 text-primary-ink border-primary/40",
|
|
38
39
|
muted: "bg-surface-alt text-muted border-subtle"
|
|
39
40
|
}.freeze
|
|
40
41
|
|
|
@@ -44,7 +45,7 @@ module Studio
|
|
|
44
45
|
"warning" => "bg-yellow-500/10 text-yellow-400 border-yellow-500/30",
|
|
45
46
|
"info" => "bg-blue-500/10 text-blue-500 border-blue-500/30",
|
|
46
47
|
"violet" => "bg-violet/10 text-violet border-violet/30",
|
|
47
|
-
"primary" => "bg-primary/10 text-primary border-primary/30",
|
|
48
|
+
"primary" => "bg-primary/10 text-primary-ink border-primary/30",
|
|
48
49
|
"orange" => "bg-orange-500/10 text-orange-500 border-orange-500/30",
|
|
49
50
|
"emerald" => "bg-emerald-500/10 text-emerald-500 border-emerald-500/30",
|
|
50
51
|
"gray" => "bg-gray-500/10 text-gray-400 border-gray-500/30",
|
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
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
<div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
|
|
28
28
|
<span><%= section[:title] %></span>
|
|
29
29
|
<% if section[:admin] %>
|
|
30
|
-
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
|
|
30
|
+
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
|
|
31
31
|
<% end %>
|
|
32
32
|
</div>
|
|
33
33
|
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
<% if sidebar_admin && sections.any? { |section| section[:admin] } %>
|
|
93
93
|
<div class="px-4 pt-4 pb-1 label-upper font-bold text-muted flex items-center gap-2">
|
|
94
94
|
<span>Geo</span>
|
|
95
|
-
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary">ADMIN</span>
|
|
95
|
+
<span class="rounded border border-primary/20 bg-primary/10 px-1.5 py-0.5 text-[10px] font-mono text-primary-ink">ADMIN</span>
|
|
96
96
|
</div>
|
|
97
97
|
<%= render "components/geo_signpost", variant: :sidebar, close_action: close_action %>
|
|
98
98
|
<% end %>
|
|
@@ -85,7 +85,7 @@
|
|
|
85
85
|
<%= link_to entry.label, admin_email_path(entry.key),
|
|
86
86
|
class: "font-semibold text-heading hover:text-primary underline-offset-2 hover:underline" %>
|
|
87
87
|
<% if entry.marketing? %>
|
|
88
|
-
<span class="badge bg-primary/10 text-primary border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
|
|
88
|
+
<span class="badge bg-primary/10 text-primary-ink border-primary/30 ml-1.5 whitespace-nowrap">Marketing</span>
|
|
89
89
|
<% end %>
|
|
90
90
|
<% if entry.description.present? %>
|
|
91
91
|
<p class="text-sm text-body mt-0.5 max-w-md"><%= entry.description %></p>
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
</div>
|
|
104
104
|
|
|
105
105
|
<div class="flex flex-wrap items-center gap-2 mt-3">
|
|
106
|
-
<span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
|
|
106
|
+
<span class="badge <%= @entry.marketing? ? "bg-primary/10 text-primary-ink border-primary/30" : "bg-inset text-muted border-subtle" %> whitespace-nowrap">
|
|
107
107
|
<%= @entry.type.to_s.titleize %>
|
|
108
108
|
</span>
|
|
109
109
|
<span class="<%= badge_class %> whitespace-nowrap"><%= badge_label %></span>
|
|
@@ -124,7 +124,7 @@
|
|
|
124
124
|
</div>
|
|
125
125
|
<% end %>
|
|
126
126
|
<% chrome_kickoff = capture do %>
|
|
127
|
-
<span class="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-primary">kickoff slot</span>
|
|
127
|
+
<span class="inline-flex items-center gap-1 rounded border border-primary/30 bg-primary/10 px-1.5 py-0.5 text-2xs font-semibold uppercase tracking-wide text-primary-ink">kickoff slot</span>
|
|
128
128
|
<% end %>
|
|
129
129
|
<%
|
|
130
130
|
# Per-column count_class colours (raw classes, the task_stage_count_classes shape),
|
|
@@ -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
|
|
@@ -117,6 +117,10 @@ module Studio
|
|
|
117
117
|
"--color-danger-ink" => status_ink(danger, surfaces, direction: :lighten),
|
|
118
118
|
"--color-warning-ink" => status_ink(warning, surfaces, direction: :lighten),
|
|
119
119
|
"--color-success-ink" => status_ink(success, surfaces, direction: :lighten),
|
|
120
|
+
# The primary role as chip TEXT (`bg-primary/10 text-primary-ink`): the
|
|
121
|
+
# brand hue, shaded per theme until it clears AA on the surfaces and on
|
|
122
|
+
# its own tint. --color-primary itself stays the brand colour.
|
|
123
|
+
"--color-primary-ink" => status_ink(primary, surfaces, direction: :lighten),
|
|
120
124
|
"--color-accent" => colors[:accent] || "#F72585"
|
|
121
125
|
}.merge(fill_vars(primary, warning, danger))
|
|
122
126
|
end
|
|
@@ -165,6 +169,16 @@ module Studio
|
|
|
165
169
|
# How strong a status role's tint is where its ink is read on it: the
|
|
166
170
|
# `bg-<role>/10 text-<role>-ink` badge and flash-panel pattern.
|
|
167
171
|
STATUS_TINT = 0.10
|
|
172
|
+
# Every tint strength a role's ink is read on. The 10% chip, as written and
|
|
173
|
+
# as an 8-bit alpha channel holds it (26/255, a little stronger), and the
|
|
174
|
+
# 15% and 20% tints the apps also put an ink on: a selected or "Entered"
|
|
175
|
+
# badge (`bg-primary/15`), the hub's workflow and verdict chips
|
|
176
|
+
# (`bg-success/20 text-success-ink`, `bg-warning/20 text-warning-ink`),
|
|
177
|
+
# turf's signer roster (`bg-danger/15 text-danger-ink`). A stronger tint
|
|
178
|
+
# sits closer to the ink, so an ink tuned to the 10% chip alone read 3.89:1
|
|
179
|
+
# on a 20% one (default warning, dark). 0.15 and 0.20 are the stronger of
|
|
180
|
+
# each written alpha and its 8-bit value (38/255 is weaker, 51/255 equal).
|
|
181
|
+
TINT_ALPHAS = [STATUS_TINT, (STATUS_TINT * 255).round / 255.0, 0.15, 0.20].freeze
|
|
168
182
|
|
|
169
183
|
# A status ink lands on the page surfaces AND on its own role's tint over
|
|
170
184
|
# each of them (the `bg-warning/10 text-warning-ink` badge). The tint is
|
|
@@ -172,9 +186,28 @@ module Studio
|
|
|
172
186
|
# to the bare surfaces alone comes out below AA inside its own badge
|
|
173
187
|
# (default theme: warning 3.91:1 dark, success 4.00:1 dark, danger 4.10:1
|
|
174
188
|
# light and 4.18:1 dark). Counting the tints as surfaces closes that.
|
|
189
|
+
#
|
|
190
|
+
# The tint is counted at every strength in TINT_ALPHAS. Chromium composites
|
|
191
|
+
# a 10% fill at 26/255, a little stronger than 0.10, and an ink tuned to
|
|
192
|
+
# 0.10 alone measured 4.48:1 on the painted pixels (danger, dark, on a
|
|
193
|
+
# #141516 base).
|
|
194
|
+
#
|
|
195
|
+
# Each tint is also counted one unit lighter and one unit darker in every
|
|
196
|
+
# channel. A browser composites in 8 bits and rounds the fill's share and
|
|
197
|
+
# the surface's share separately, so a painted tint can sit one unit off
|
|
198
|
+
# the exact blend: the default danger at 20% over #3C3853 blends to
|
|
199
|
+
# #5F3A50 and Chromium paints #603B50, which took an ink graded 4.51:1 here
|
|
200
|
+
# to 4.47:1 on screen. Contrast moves with luminance, and luminance with
|
|
201
|
+
# each channel, so the two all-channel neighbours bound every rounding.
|
|
175
202
|
def status_ink(role, surfaces, direction:)
|
|
176
|
-
tints = surfaces.map { |bg| ColorScale.blend(role, bg,
|
|
177
|
-
|
|
203
|
+
tints = TINT_ALPHAS.flat_map { |alpha| surfaces.map { |bg| ColorScale.blend(role, bg, alpha) } }
|
|
204
|
+
painted = tints.flat_map { |tint| [tint, nudge(tint, 1), nudge(tint, -1)] }
|
|
205
|
+
contrast_ink(role, direction: direction, start: 0.0, target: 4.5, against: surfaces + painted)
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
# `hex` with every channel moved `units` (clamped to 0..255).
|
|
209
|
+
def nudge(hex, units)
|
|
210
|
+
ColorScale.rgb_to_hex(*ColorScale.hex_to_rgb(hex).map { |c| c + units })
|
|
178
211
|
end
|
|
179
212
|
|
|
180
213
|
# The lightest shade of `color` that carries a white label at AA: the
|
|
@@ -307,6 +340,7 @@ module Studio
|
|
|
307
340
|
"--color-danger-ink" => status_ink(danger, surfaces, direction: :darken),
|
|
308
341
|
"--color-warning-ink" => status_ink(warning, surfaces, direction: :darken),
|
|
309
342
|
"--color-success-ink" => status_ink(success, surfaces, direction: :darken),
|
|
343
|
+
"--color-primary-ink" => status_ink(primary, surfaces, direction: :darken),
|
|
310
344
|
"--color-accent" => colors[:accent] || "#F72585"
|
|
311
345
|
}.merge(fill_vars(primary, warning, danger))
|
|
312
346
|
end
|
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
|
|
@@ -103,6 +103,9 @@ module.exports = {
|
|
|
103
103
|
'danger-ink': 'var(--color-danger-ink)',
|
|
104
104
|
'warning-ink': 'var(--color-warning-ink)',
|
|
105
105
|
'success-ink': 'var(--color-success-ink)',
|
|
106
|
+
// The primary role as chip text, derived the same way. text-primary
|
|
107
|
+
// stays the brand colour for links, icons and large text.
|
|
108
|
+
'primary-ink': 'var(--color-primary-ink)',
|
|
106
109
|
},
|
|
107
110
|
// The status-role FILLS: badge tints, flash panels, borders. Registered
|
|
108
111
|
// for backgrounds and borders ONLY (not `colors`), so that registering
|
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.rc2
|
|
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
|