studio-engine 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 440b221a14bf9d80f6e58358f49d7b84f6495361e8d8a1a68e00b3980e02f024
4
- data.tar.gz: 1efda067ddfb1277ceb5cfd6b399195689081c3d02b077489e75ff0c756aaf19
3
+ metadata.gz: 940823449254339d163e66fe93eabdab83cc36e25f4b80600ab02c6552e3d624
4
+ data.tar.gz: 1ce338c5ba90749bbd3a5217c5dba0bd3813b5be96996c15c734cdacdb1b0f0d
5
5
  SHA512:
6
- metadata.gz: bde626a90189833853f31e49c8767d72fe48f82f99b06a3c7b3c282a790b55dc8479fcf066c12d7fe5324e7f84e6aa1c2f22536fa2af98908cb6c7cf237f30b2
7
- data.tar.gz: 5e5034bebe474a62d7821e9e8cc3efd23f23a2daad1ac709a335bc88f52c8c816e37c121ae710ddc0643d5fad26a0912297dd47c6451835b7af23f0bd294fa14
6
+ metadata.gz: c322848f2708206583cff895faf563e34b85853494172dfd6f859568a771555a5f5b7859c0ac517a361679a282dbe5af1e5aa91b4bdce9a5e5df57c38a4cbf59
7
+ data.tar.gz: 70c41c33f57623eee5a8fe63aab6ff5c804a3570f055cca12bde70c8ae0919b20c3026de2100518e01ccab75c79f77afcc21674dede4c76ea7d6b1be4b6d5e0f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,51 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## 0.85.0 — 2026-10-02
8
+
9
+ ### Changed
10
+
11
+ - **`Studio::S3.delete` now moves the object to `trash/` instead of deleting
12
+ it.** The object is copied to `trash/<UTC date>/<epoch ms>/<key>` in the same
13
+ bucket, then deleted; a failed copy raises and never deletes. It returns the
14
+ trash key (nil when nothing was there) rather than the S3 response. The
15
+ bucket's lifecycle rule must expire `trash/` after three days, or trash never
16
+ expires: set it up before upgrading (README, *Trash and restore*). The
17
+ `/admin/emails` banner and logo replace and revert paths go through this
18
+ delete.
19
+ - **`Studio::S3.list` leaves `trash/` keys out.** Pass `include_trash: true` to
20
+ keep them. The filter runs after `max_keys`, so a page can come back short.
21
+ - **The engine's auth pages speak `Studio.sign_in_label`.** 0.80.0 put the label
22
+ on the navbar's signed-out buttons only, so an app that set `"Sign in"` still
23
+ showed "Log in to continue", a "Log In" button and a "Log in" link on its
24
+ `/login` and `/signup` pages. Those words, the magic-link button, the SSO
25
+ divider, the magic-link confirm page's button and the link-sent notice now
26
+ derive from the label (`lib/studio/auth_labels.rb`; README, *Navbar
27
+ identity*). An app that leaves the label at its default renders those pages
28
+ byte-identical to before. No routes change.
29
+
30
+ ### Added
31
+
32
+ - **`Studio::S3.purge!(key:)`**, the hard delete, for objects with no value
33
+ after deletion.
34
+ - **A production-bucket guard.** `Studio::S3.delete`, `purge!`, and the trash
35
+ service's `delete` and `delete_prefixed` raise
36
+ `Studio::S3::Trash::ProductionBucketRefused` before any request when the
37
+ bucket ends in `-production` and the process is not production (`QA_ENV`
38
+ first, then `Rails.env`).
39
+ - **`ActiveStorage::Service::StudioTrashS3Service`** (`service: StudioTrashS3`
40
+ in `config/storage.yml`): Active Storage's S3 service whose `delete` trashes
41
+ the blob's object, recording the content type, byte size, checksum and
42
+ filename it can read off the object so a blob row can be rebuilt.
43
+ `delete_prefixed` (variants) stays a hard delete. Opt-in per app.
44
+ - **`rake studio:trash:list[KEY]` and `studio:trash:restore[TRASH_KEY]`**: find
45
+ a key's trash copies, and copy one back to its original key (`FORCE=1` to
46
+ overwrite, `SERVICE=<name>` for an Active Storage bucket). A restore prints the
47
+ `ActiveStorage::Blob.create!` that re-creates the blob row.
48
+ - **`Studio::S3::Trash`**, the shared copy-then-delete, key mapping, listing and
49
+ restore. A single CopyObject moves at most 5 GiB, so a larger object raises
50
+ `TooLarge` and is left in place.
51
+
7
52
  ## 0.84.0 — 2026-10-01
8
53
 
9
54
  ### Changed
data/README.md CHANGED
@@ -629,7 +629,8 @@ Studio.configure do |config|
629
629
  config.navbar_user_name = :player_name # a method on the user
630
630
  # config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
631
631
 
632
- # The signed-out button in layouts/_navbar and components/_user_nav.
632
+ # The signed-out button in layouts/_navbar and components/_user_nav, and the
633
+ # sign-in wording on the engine's own auth pages (below).
633
634
  config.sign_in_label = "Sign in" # default "Log in"
634
635
  end
635
636
  ```
@@ -646,6 +647,24 @@ end
646
647
  - The engine has no I18n catalogue, so the label is plain config, not a locale
647
648
  key. A blank or non-String label, or a `navbar_user_name` of any other type,
648
649
  raises `Studio::NavbarIdentity::InvalidConfig` at assignment.
650
+ - The label also sets the sign-in words on the engine's own auth pages, so the
651
+ navbar and `/login` never disagree. Rules: `lib/studio/auth_labels.rb`.
652
+
653
+ | Where | `"Log in"` (default) | `"Sign in"` |
654
+ |-------|----------------------|-------------|
655
+ | `/login` prompt | Log in to continue | Sign in to continue |
656
+ | `/login` password button | Log In | Sign In |
657
+ | `/login` magic-link button | Send sign-in link | Send sign-in link |
658
+ | `/login` SSO divider | or sign in below | or sign in below |
659
+ | `/signup` link back to `/login` | Log in | Sign in |
660
+ | Magic-link confirm page button | Sign in to *App* | Sign in to *App* |
661
+ | Link-sent notice | …emailed you a sign-in link. | …emailed you a sign-in link. |
662
+
663
+ The default reproduces the pages' earlier wording byte for byte, including the
664
+ four places that already said "sign in". Any other label derives every form:
665
+ `"Log on"` gives *Log On*, *Log on to continue*, *or log on below* and
666
+ *log-on link*. The "Sign in with Google" and "Sign up" buttons are not
667
+ sign-in labels and do not change.
649
668
 
650
669
  ### User nav slots
651
670
 
@@ -920,6 +939,9 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
920
939
  endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
921
940
  `upload` writes but returns `nil`; serve private objects with `signed_url`.
922
941
 
942
+ `Studio::S3.delete` moves the object to `trash/` for three days rather than
943
+ deleting it; see [Trash and restore](#trash-and-restore).
944
+
923
945
  An app with **no** bucket configured does not error — `/admin/emails` renders
924
946
  read-only, showing the inherited defaults it is genuinely sending and naming the
925
947
  one setting that turns uploads on.
@@ -932,6 +954,137 @@ Add it to the app's admin sidebar section:
932
954
  { label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
933
955
  ```
934
956
 
957
+ ## Trash and restore
958
+
959
+ A delete through the engine is recoverable for three days. R2 has no object
960
+ versioning, so instead of deleting, the engine **moves** the object under
961
+ `trash/` in the same bucket, and a lifecycle rule on the bucket expires
962
+ `trash/` after three days:
963
+
964
+ ```text
965
+ avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
966
+ trash/<UTC date>/<epoch ms>/<original key>
967
+ ```
968
+
969
+ The copy goes first and the delete second. A copy that fails raises and the
970
+ delete is never sent, so a failure leaves the original in place. The trash copy
971
+ keeps the object's content type, cache headers and metadata, and adds
972
+ `original-key`, `deleted-at`, `deleted-env`, and what an Active Storage blob row
973
+ needs to be rebuilt (`blob-content-type`, `blob-byte-size`, `blob-checksum`
974
+ from a single-part ETag, and `blob-filename` when the upload carried a
975
+ Content-Disposition). A single CopyObject moves at most 5 GiB, so a larger
976
+ object raises `Studio::S3::Trash::TooLarge` and stays where it is.
977
+
978
+ `trash/` always sits at the bucket **root**, even for an app under a
979
+ `s3_key_prefix` in a shared bucket, so one lifecycle rule and one Cloudflare
980
+ rule cover every app in the bucket.
981
+
982
+ ### What deletes, and what purges
983
+
984
+ | Call | Effect |
985
+ |------|--------|
986
+ | `Studio::S3.delete(key:)` | Moves to trash; returns the trash key (nil if nothing was there) |
987
+ | `Studio::S3.purge!(key:)` | Hard delete, gone at once |
988
+ | `Studio::S3.list` | Leaves `trash/` keys out; `include_trash: true` keeps them |
989
+ | Active Storage `service: StudioTrashS3`, `delete` | Moves the blob's object to trash (`Blob#purge`, a replaced attachment) |
990
+ | Active Storage `service: StudioTrashS3`, `delete_prefixed` | Hard delete: Active Storage calls it for `variants/<key>/`, which regenerate from the original |
991
+
992
+ **The production guard.** All four destructive paths refuse a bucket whose name
993
+ ends in `-production` when the process is not production, by `Studio::S3`'s own
994
+ resolution (`QA_ENV` first, so a QA app running Rails as production is NOT
995
+ production, then `Rails.env`). They raise
996
+ `Studio::S3::Trash::ProductionBucketRefused` before sending any request. The
997
+ guard reads the bucket NAME, so it protects only buckets that follow the
998
+ `<app>-production` convention.
999
+
1000
+ ### Set up the bucket (once per bucket, before an app adopts)
1001
+
1002
+ Without the lifecycle rule, trash never expires. In the Cloudflare dashboard:
1003
+ R2 → the bucket → Settings → Object lifecycle rules → add a rule for prefix
1004
+ `trash/` that deletes objects 3 days after upload. A trash copy is a new object,
1005
+ so "uploaded" is the moment it was deleted. The same rule as S3 lifecycle JSON
1006
+ (R2's S3 API and AWS both take it). `aws s3api
1007
+ put-bucket-lifecycle-configuration` REPLACES the bucket's whole lifecycle
1008
+ configuration, R2's default abort-incomplete-multipart rule included, so read
1009
+ the current rules first (`get-bucket-lifecycle-configuration`) and send them
1010
+ together with this one, or use the dashboard:
1011
+
1012
+ ```json
1013
+ {
1014
+ "Rules": [
1015
+ {
1016
+ "ID": "expire-trash-3d",
1017
+ "Status": "Enabled",
1018
+ "Filter": { "Prefix": "trash/" },
1019
+ "Expiration": { "Days": 3 }
1020
+ }
1021
+ ]
1022
+ }
1023
+ ```
1024
+
1025
+ A public bucket also serves `trash/` from its custom domain unless it is
1026
+ blocked, which would keep a "deleted" profile picture reachable by URL for three
1027
+ days. Add a Cloudflare WAF custom rule on the zone, action **Block**:
1028
+
1029
+ ```text
1030
+ (http.host in {"assets.example.com"} and starts_with(http.request.uri.path, "/trash/"))
1031
+ ```
1032
+
1033
+ Name every `assets.<domain>` host the bucket answers on. If the bucket has the
1034
+ `r2.dev` public URL enabled, turn it off; a WAF rule cannot cover it.
1035
+
1036
+ ### Adopt it in an app
1037
+
1038
+ Active Storage: change the R2 service in `config/storage.yml` from `service: S3`
1039
+ to `service: StudioTrashS3`. Every other key stays as it is:
1040
+
1041
+ ```yaml
1042
+ r2:
1043
+ service: StudioTrashS3
1044
+ bucket: my-app-production
1045
+ endpoint: <%= ENV["R2_ENDPOINT"] %>
1046
+ region: auto
1047
+ access_key_id: <%= ENV["R2_ACCESS_KEY_ID"] %>
1048
+ secret_access_key: <%= ENV["R2_SECRET_ACCESS_KEY"] %>
1049
+ ```
1050
+
1051
+ An app with its own S3 service subclass (turf-monster's
1052
+ `ActiveStorage::Service::R2PublicService`) inherits from the trash service
1053
+ instead:
1054
+
1055
+ ```ruby
1056
+ require "active_storage/service/studio_trash_s3_service"
1057
+
1058
+ module ActiveStorage
1059
+ class Service::R2PublicService < Service::StudioTrashS3Service
1060
+ # ...
1061
+ end
1062
+ end
1063
+ ```
1064
+
1065
+ `Studio::S3` callers need no change: `delete` trashes from this release on. Call
1066
+ `purge!` where an object truly has no value after deletion.
1067
+
1068
+ ### Find and restore a deleted object
1069
+
1070
+ ```bash
1071
+ bin/rails "studio:trash:list" # everything in trash
1072
+ bin/rails "studio:trash:list[avatars/abc.png]" # one key's trash copies
1073
+ bin/rails "studio:trash:restore[trash/2026-10-01/1759302000123/avatars/abc.png]"
1074
+ ```
1075
+
1076
+ Both read `Studio::S3`'s bucket. `SERVICE=<storage.yml service name>` reads an
1077
+ Active Storage service's bucket instead, e.g. `SERVICE=r2`. A restore copies the
1078
+ object back to its original key and leaves the trash copy for the lifecycle rule
1079
+ to expire. It refuses to overwrite an object already at the original key unless
1080
+ `FORCE=1`.
1081
+
1082
+ Restoring an Active Storage object restores its **bytes only**. The blob row was
1083
+ destroyed before the object was trashed, so the task prints the
1084
+ `ActiveStorage::Blob.create!(...)` to run, filled from the trash copy's metadata,
1085
+ and the record must be re-attached by hand. `filename` is known only when the
1086
+ upload carried a Content-Disposition; supply it otherwise.
1087
+
935
1088
  ## Overriding Views
936
1089
 
937
1090
  This is a non-isolated engine -- app views at the same path automatically override engine views. For example, placing `app/views/sessions/new.html.erb` in the consuming app replaces the engine's login page.
@@ -28,7 +28,7 @@ class MagicLinksController < ApplicationController
28
28
  end
29
29
  respond_to do |format|
30
30
  format.json { render json: { success: true } }
31
- format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link." }
31
+ format.html { redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}." }
32
32
  end
33
33
  end
34
34
 
@@ -18,7 +18,7 @@ class RegistrationsController < ApplicationController
18
18
  token = issue_magic_link(email, nil)
19
19
  Studio::Email.deliver(UserMailer, :magic_link, email, token, to: email)
20
20
  end
21
- return redirect_to login_path, notice: "Check your inbox — we just emailed you a sign-in link."
21
+ return redirect_to login_path, notice: "Check your inbox — we just emailed you a #{Studio::AuthLabels.link_noun(Studio.sign_in_label)}."
22
22
  end
23
23
 
24
24
  @user = User.new(user_params)
@@ -73,7 +73,7 @@
73
73
 
74
74
  <p class="text-center text-secondary text-sm mt-6">
75
75
  Already have an account?
76
- <%= link_to "Log in", login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
76
+ <%= link_to Studio::AuthLabels.link(Studio.sign_in_label), login_path, class: "text-primary hover:text-primary-300 font-medium underline underline-offset-2" %>
77
77
  </p>
78
78
  </div>
79
79
  </div>
@@ -11,7 +11,7 @@
11
11
 
12
12
  <div class="flex items-center gap-3 mt-5">
13
13
  <div class="flex-1 h-px border-t border-subtle"></div>
14
- <span class="text-muted text-xs uppercase tracking-wider">or sign in below</span>
14
+ <span class="text-muted text-xs uppercase tracking-wider"><%= Studio::AuthLabels.or_below(Studio.sign_in_label) %></span>
15
15
  <div class="flex-1 h-px border-t border-subtle"></div>
16
16
  </div>
17
17
  </div>
@@ -4,6 +4,9 @@
4
4
  words = Studio.app_name.split
5
5
  last_word = words.pop
6
6
  prefix = words.join(" ")
7
+ # The sign-in wording follows Studio.sign_in_label, as the navbar does
8
+ # (lib/studio/auth_labels.rb); the default label prints the wording these pages always had.
9
+ sign_in_label = Studio.sign_in_label
7
10
  %>
8
11
 
9
12
  <div class="min-h-[70vh] flex items-center justify-center">
@@ -13,7 +16,7 @@
13
16
  <img src="<%= logo_path %>" alt="<%= Studio.app_name %>" class="w-20 h-20 rounded-full shadow-lg mx-auto mb-4">
14
17
  <% end %>
15
18
  <h1 class="text-3xl font-extrabold text-heading tracking-tight"><%= prefix %> <span class="text-primary"><%= last_word %></span></h1>
16
- <p class="text-secondary mt-2">Log in to continue</p>
19
+ <p class="text-secondary mt-2"><%= Studio::AuthLabels.prompt(sign_in_label) %></p>
17
20
  </div>
18
21
 
19
22
  <% if has_sso %>
@@ -57,7 +60,7 @@
57
60
  </div>
58
61
 
59
62
  <button type="submit" class="btn btn-primary btn-lg w-full">
60
- Log In
63
+ <%= Studio::AuthLabels.button(sign_in_label) %>
61
64
  </button>
62
65
  <% end %>
63
66
  <% elsif Studio.auth_method?(:magic_link) %>
@@ -71,7 +74,7 @@
71
74
  </div>
72
75
 
73
76
  <button type="submit" class="btn btn-primary btn-lg w-full">
74
- Send sign-in link
77
+ Send <%= Studio::AuthLabels.link_noun(sign_in_label) %>
75
78
  </button>
76
79
  <% end %>
77
80
  <% end %>
@@ -75,7 +75,7 @@
75
75
  method: :post,
76
76
  form: { id: "magic-consume-form", data: { turbo: "false" } },
77
77
  class: "magic-submit" do %>
78
- Sign in to <%= Studio.app_name %>
78
+ <%= Studio::AuthLabels.to_app(Studio.sign_in_label, Studio.app_name) %>
79
79
  <% end %>
80
80
  </div>
81
81
  </main>
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_storage/service/s3_service"
4
+ require_relative "../../studio/s3"
5
+
6
+ module ActiveStorage
7
+ # Active Storage's S3 service with a three-day grace period on delete. An app
8
+ # opts in from config/storage.yml by naming it instead of S3:
9
+ #
10
+ # r2_production:
11
+ # service: StudioTrashS3
12
+ # bucket: turf-monster-production
13
+ # endpoint: <%= ENV["R2_ENDPOINT"] %>
14
+ # ...
15
+ #
16
+ # Active Storage resolves `service: StudioTrashS3` by requiring
17
+ # "active_storage/service/studio_trash_s3_service", which is why this file
18
+ # lives at lib/active_storage/service/ on the gem's load path. Nothing
19
+ # autoloads it; Zeitwerk never sees lib/.
20
+ #
21
+ # delete(key) — what Blob#purge and a replaced attachment end in — MOVES the
22
+ # object under trash/ (Studio::S3::Trash) instead of deleting it. By the time
23
+ # it runs the blob row is already destroyed, so what the trash copy records
24
+ # about the blob (content type, byte size, checksum, filename when the upload
25
+ # carried one) is read back off the object itself.
26
+ #
27
+ # delete_prefixed(prefix) stays a HARD delete. Active Storage calls it for
28
+ # "variants/<key>/": derived images, regenerated on demand from the original,
29
+ # which is the object that matters and the one trashed.
30
+ #
31
+ # Both refuse a "*-production" bucket from a non-production process.
32
+ class Service::StudioTrashS3Service < Service::S3Service
33
+ def delete(key)
34
+ instrument :delete, key: key do
35
+ Studio::S3.guard_production_bucket!(bucket.name)
36
+ Studio::S3::Trash.trash!(client: client.client, bucket: bucket.name, key: key,
37
+ env: Studio::S3.deletion_environment)
38
+ rescue StandardError => error
39
+ # Logged, then RE-RAISED. Blob#purge has already destroyed the row, so a
40
+ # raise here leaks the object rather than losing it: the failure that
41
+ # costs storage, never the one that costs a user's file.
42
+ log_trash_failure(key, error)
43
+ raise
44
+ end
45
+ end
46
+
47
+ def delete_prefixed(prefix)
48
+ Studio::S3.guard_production_bucket!(bucket.name)
49
+ super
50
+ end
51
+
52
+ private
53
+
54
+ def log_trash_failure(key, error)
55
+ message = "[StudioTrashS3] trash of #{bucket.name}/#{key} failed, object left in place: " \
56
+ "#{error.class}: #{error.message}"
57
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
58
+ Rails.logger.error(message)
59
+ else
60
+ warn message
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The words the engine's own auth pages say for "sign in", derived from the one
4
+ # setting the navbar already reads, Studio.sign_in_label (default "Log in";
5
+ # lib/studio/navbar_identity.rb). An app that says "Sign in" in its navbar gets
6
+ # "Sign in" on /login, /signup and the magic-link pages too, never a mix.
7
+ #
8
+ # Every form is derived from the label as written:
9
+ #
10
+ # label "Sign in" link "Sign in" button "Sign In"
11
+ # prompt "Sign in to continue"
12
+ # to_app "Sign in to Cyvasse"
13
+ # or_below "or sign in below"
14
+ # link_noun "sign-in link"
15
+ #
16
+ # THE DEFAULT IS PINNED, NOT DERIVED. With the default label the pages print
17
+ # exactly what they printed before this setting reached them, and that text was
18
+ # itself mixed: "Log in to continue" and "Log In" beside "Sign in to <App>",
19
+ # "or sign in below" and "sign-in link". So the three forms that said "sign in"
20
+ # under the default keep saying it while the label is the default, and follow the
21
+ # label only once an app configures another one. An app that never configures
22
+ # the label renders byte-identical pages (test/integration/auth_page_labels_render_test.rb).
23
+ #
24
+ # Pure Ruby, so the unit suite covers it without the dummy app.
25
+ require_relative "navbar_identity"
26
+
27
+ module Studio
28
+ module AuthLabels
29
+ DEFAULT = NavbarIdentity::DEFAULT_SIGN_IN_LABEL
30
+
31
+ module_function
32
+
33
+ def configured?(label)
34
+ label.to_s != DEFAULT
35
+ end
36
+
37
+ # The sign-up page's "Already have an account?" link.
38
+ def link(label)
39
+ label.to_s
40
+ end
41
+
42
+ # The login form's submit button, in title case: "Log In", "Sign In".
43
+ def button(label)
44
+ label.to_s.split(" ").map { |word| capitalize_first(word) }.join(" ")
45
+ end
46
+
47
+ # The login page's line under the app name.
48
+ def prompt(label)
49
+ "#{label} to continue"
50
+ end
51
+
52
+ # The magic-link confirm page's fallback button.
53
+ def to_app(label, app_name)
54
+ "#{configured?(label) ? label : 'Sign in'} to #{app_name}"
55
+ end
56
+
57
+ # The divider under the SSO "Continue as" button.
58
+ def or_below(label)
59
+ configured?(label) ? "or #{label.to_s.downcase} below" : "or sign in below"
60
+ end
61
+
62
+ # The emailed link's noun: the magic-link button and the "sent" notice.
63
+ def link_noun(label)
64
+ configured?(label) ? "#{label.to_s.downcase.tr(' ', '-')} link" : "sign-in link"
65
+ end
66
+
67
+ # Only the first letter changes, so an already-capital word is untouched.
68
+ def capitalize_first(word)
69
+ word.sub(/\A\p{Ll}/) { |c| c.upcase }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,248 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "erb"
5
+ require "uri"
6
+ require_relative "../s3" unless defined?(Studio::S3::Error)
7
+
8
+ module Studio
9
+ module S3
10
+ # THE GRACE PERIOD ON A DELETE. R2 has no object versioning, so a delete is
11
+ # forever the moment it lands: a replaced profile picture, a purged
12
+ # attachment, a banner swapped in /admin/emails. Trash turns that delete into
13
+ # a MOVE inside the same bucket:
14
+ #
15
+ # avatars/abc.png -> trash/2026-10-01/1759302000123/avatars/abc.png
16
+ #
17
+ # and the bucket's lifecycle rule (set on the bucket, not here) expires
18
+ # everything under trash/ after three days. Until then the object can be put
19
+ # back with `rake studio:trash:restore[TRASH_KEY]`.
20
+ #
21
+ # THE ORDER IS THE SAFETY. Copy first, delete second, and a copy that fails
22
+ # RAISES before the delete is ever sent. A failure anywhere leaves the
23
+ # original where it was: a leaked object costs storage, a lost one costs a
24
+ # user's photo.
25
+ #
26
+ # Everything here works on REAL object keys (any Studio.s3_key_prefix
27
+ # already applied) and an explicit client + bucket, so Studio::S3 and the
28
+ # Active Storage service (ActiveStorage::Service::StudioTrashS3Service) share
29
+ # one implementation. trash/ always sits at the bucket ROOT, even for an app
30
+ # that lives under a key prefix in a shared bucket, so ONE lifecycle rule and
31
+ # ONE Cloudflare block cover every app in the bucket.
32
+ module Trash
33
+ PREFIX = "trash/"
34
+
35
+ # A single CopyObject copies at most 5 GiB (S3 and R2 alike). Larger
36
+ # objects need a multipart copy, which this does not do: it refuses
37
+ # instead, and leaves the object in place. Nothing in the fleet is near
38
+ # it (moms-app's audio is ~650 MB).
39
+ MAX_COPY_BYTES = 5 * 1024**3
40
+
41
+ # The metadata trash! writes. restore! strips exactly these back off, so a
42
+ # restored object carries the metadata it was uploaded with and no more.
43
+ TRASH_METADATA_KEYS = %w[
44
+ original-key deleted-at deleted-env
45
+ blob-content-type blob-byte-size blob-checksum blob-filename
46
+ ].freeze
47
+
48
+ # Header fields a REPLACE copy would otherwise drop.
49
+ CARRIED_HEADERS = %i[content_type cache_control content_disposition content_encoding content_language].freeze
50
+
51
+ class TooLarge < Studio::S3::Error; end
52
+ class ProductionBucketRefused < Studio::S3::Error; end
53
+ class NotTrash < Studio::S3::Error; end
54
+ class RestoreConflict < Studio::S3::Error; end
55
+
56
+ TRASH_KEY = %r{\Atrash/(\d{4}-\d{2}-\d{2})/(\d+)/(.+)\z}m
57
+
58
+ class << self
59
+ # Move bucket/key under trash/ and return the trash key. Returns nil,
60
+ # sending no copy and no delete, when the object is already gone: an
61
+ # S3 delete of a missing key succeeds, and so does this.
62
+ def trash!(client:, bucket:, key:, env:, now: Time.now.utc)
63
+ head = head(client, bucket, key)
64
+ return nil unless head
65
+
66
+ size = head.content_length.to_i
67
+ if size > MAX_COPY_BYTES
68
+ raise TooLarge, "#{bucket}/#{key} is #{size} bytes; a single CopyObject moves at most " \
69
+ "#{MAX_COPY_BYTES} (5 GiB), so it was NOT trashed and NOT deleted. " \
70
+ "Delete it with Studio::S3.purge! if you mean it, or copy it aside by hand first."
71
+ end
72
+
73
+ trash_key = key_for(key, now: now)
74
+ client.copy_object(
75
+ bucket: bucket,
76
+ copy_source: copy_source(bucket, key),
77
+ key: trash_key,
78
+ metadata_directive: "REPLACE",
79
+ metadata: trash_metadata(head, key: key, env: env, now: now),
80
+ **carried_headers(head)
81
+ )
82
+ # Reached only when the copy returned. A raise above never deletes.
83
+ client.delete_object(bucket: bucket, key: key)
84
+ trash_key
85
+ end
86
+
87
+ # trash/<UTC date>/<epoch ms>/<original key>. The date makes a day's
88
+ # deletes easy to browse; the milliseconds keep two deletes of the same
89
+ # key (a profile picture replaced twice in a day) from colliding.
90
+ def key_for(key, now: Time.now.utc)
91
+ now = now.utc
92
+ "#{PREFIX}#{now.strftime('%Y-%m-%d')}/#{(now.to_r * 1000).floor}/#{key}"
93
+ end
94
+
95
+ # The original key a trash key was moved from. It is read from the trash
96
+ # key's own path, not from metadata, so it answers even for an object
97
+ # whose metadata a hand copy dropped.
98
+ def original_key(trash_key)
99
+ match = TRASH_KEY.match(trash_key.to_s)
100
+ raise NotTrash, "#{trash_key.inspect} is not a trash key (trash/<date>/<ms>/<key>)" unless match
101
+
102
+ match[3]
103
+ end
104
+
105
+ def trash_key?(key)
106
+ key.to_s.start_with?(PREFIX)
107
+ end
108
+
109
+ # Every trash key whose original key is `key` (any key when nil),
110
+ # oldest first. Pages through the whole trash/ prefix: it is three
111
+ # days deep, so it stays small.
112
+ def list(client:, bucket:, key: nil)
113
+ keys = []
114
+ token = nil
115
+ loop do
116
+ params = { bucket: bucket, prefix: PREFIX }
117
+ params[:continuation_token] = token if token
118
+ resp = client.list_objects_v2(**params)
119
+ resp.contents.each do |object|
120
+ next unless TRASH_KEY.match?(object.key)
121
+ next if key && original_key(object.key) != key
122
+
123
+ keys << object
124
+ end
125
+ break unless resp.is_truncated
126
+
127
+ token = resp.next_continuation_token
128
+ end
129
+ keys.sort_by(&:key)
130
+ end
131
+
132
+ # Copy a trashed object back to its original key and return that key.
133
+ # The trash copy is left alone (the lifecycle rule expires it), so a
134
+ # restore can be repeated. Refuses to overwrite a live object unless
135
+ # overwrite: true, because the original key may already hold a newer
136
+ # upload. Restores the BYTES only: an Active Storage blob row is gone
137
+ # by the time its object is trashed; see blob_attributes.
138
+ def restore!(client:, bucket:, trash_key:, overwrite: false)
139
+ original = original_key(trash_key)
140
+ head = head(client, bucket, trash_key)
141
+ raise NotTrash, "#{bucket}/#{trash_key} does not exist (expired, or never trashed)" unless head
142
+ if !overwrite && head(client, bucket, original)
143
+ raise RestoreConflict, "#{bucket}/#{original} already exists; pass overwrite (FORCE=1) to replace it"
144
+ end
145
+
146
+ client.copy_object(
147
+ bucket: bucket,
148
+ copy_source: copy_source(bucket, trash_key),
149
+ key: original,
150
+ metadata_directive: "REPLACE",
151
+ metadata: (head.metadata || {}).reject { |name, _| TRASH_METADATA_KEYS.include?(name) },
152
+ **carried_headers(head)
153
+ )
154
+ original
155
+ end
156
+
157
+ # What an Active Storage blob row needs to be rebuilt around a restored
158
+ # key, as far as the trash copy can say. filename is nil unless the
159
+ # upload carried a Content-Disposition (Active Storage only sends one
160
+ # for attachment-disposition blobs); the caller supplies it otherwise.
161
+ def blob_attributes(client:, bucket:, trash_key:)
162
+ head = head(client, bucket, trash_key)
163
+ raise NotTrash, "#{bucket}/#{trash_key} does not exist" unless head
164
+
165
+ meta = head.metadata || {}
166
+ {
167
+ key: original_key(trash_key),
168
+ filename: decode(meta["blob-filename"]),
169
+ content_type: meta["blob-content-type"] || head.content_type,
170
+ byte_size: (meta["blob-byte-size"] || head.content_length).to_i,
171
+ checksum: meta["blob-checksum"]
172
+ }
173
+ end
174
+
175
+ # CopySource is "<bucket>/<url-encoded key>"; each path segment is
176
+ # escaped and the slashes kept, the form S3 and R2 both accept.
177
+ def copy_source(bucket, key)
178
+ require "aws-sdk-s3"
179
+ "#{bucket}/#{Seahorse::Util.uri_path_escape(key)}"
180
+ end
181
+
182
+ private
183
+
184
+ def head(client, bucket, key)
185
+ client.head_object(bucket: bucket, key: key)
186
+ rescue Aws::S3::Errors::NotFound, Aws::S3::Errors::NoSuchKey
187
+ nil
188
+ end
189
+
190
+ def carried_headers(head)
191
+ CARRIED_HEADERS.each_with_object({}) do |name, out|
192
+ value = head.public_send(name)
193
+ out[name] = value unless value.nil? || value.to_s.empty?
194
+ end
195
+ end
196
+
197
+ # The original object's own metadata first, then the trash record, so a
198
+ # stale "original-key" from an earlier round trip can never win.
199
+ def trash_metadata(head, key:, env:, now:)
200
+ meta = (head.metadata || {}).to_h.dup
201
+ meta["original-key"] = encode(key)
202
+ meta["deleted-at"] = now.utc.iso8601
203
+ meta["deleted-env"] = env.to_s
204
+ meta["blob-content-type"] = head.content_type.to_s unless head.content_type.to_s.empty?
205
+ meta["blob-byte-size"] = head.content_length.to_i.to_s
206
+ checksum = checksum_from_etag(head.etag)
207
+ meta["blob-checksum"] = checksum if checksum
208
+ filename = filename_from_disposition(head.content_disposition)
209
+ meta["blob-filename"] = encode(filename) if filename
210
+ meta
211
+ end
212
+
213
+ # A single-part upload's ETag is the hex MD5 of its bytes, and Active
214
+ # Storage's blob checksum is that MD5 in base64. A multipart ETag
215
+ # ("<hex>-<parts>") is not an MD5 of the object, so it yields nothing.
216
+ def checksum_from_etag(etag)
217
+ hex = etag.to_s.delete('"')
218
+ return nil unless hex.match?(/\A\h{32}\z/)
219
+
220
+ [[hex].pack("H*")].pack("m0")
221
+ end
222
+
223
+ def filename_from_disposition(disposition)
224
+ value = disposition.to_s
225
+ if (match = value.match(/filename\*=UTF-8''([^;]+)/i))
226
+ return URI.decode_uri_component(match[1])
227
+ end
228
+ match = value.match(/filename="([^"]*)"/i) || value.match(/filename=([^;]+)/i)
229
+ match && !match[1].strip.empty? ? match[1].strip : nil
230
+ end
231
+
232
+ # S3 metadata travels as HTTP headers, so a value must be ASCII. A key
233
+ # or filename that is not is stored percent-encoded behind "url:".
234
+ def encode(value)
235
+ value = value.to_s
236
+ value.ascii_only? ? value : "url:#{ERB::Util.url_encode(value)}"
237
+ end
238
+
239
+ def decode(value)
240
+ return nil if value.nil?
241
+ return URI.decode_uri_component(value.delete_prefix("url:")) if value.start_with?("url:")
242
+
243
+ value
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
data/lib/studio/s3.rb CHANGED
@@ -55,15 +55,66 @@ module Studio
55
55
  false
56
56
  end
57
57
 
58
+ # A RECOVERABLE delete: the object moves under trash/ in the same bucket
59
+ # (Studio::S3::Trash) and the bucket's lifecycle rule expires it after
60
+ # three days. Returns the trash key, or nil when there was nothing to
61
+ # move. Refused outright when a non-production process is pointed at a
62
+ # production bucket. purge! is the delete that cannot be undone.
58
63
  def delete(key:)
59
- client.delete_object(bucket: bucket, key: full_key(key))
64
+ target = bucket
65
+ guard_production_bucket!(target)
66
+ Trash.trash!(client: client, bucket: target, key: full_key(key), env: deletion_environment)
67
+ end
68
+
69
+ # The HARD delete, gone at once with no trash copy. For objects with no
70
+ # value after deletion (regenerable derivatives, a trash copy itself).
71
+ # The same production-bucket guard as delete.
72
+ def purge!(key:)
73
+ target = bucket
74
+ guard_production_bucket!(target)
75
+ client.delete_object(bucket: target, key: full_key(key))
60
76
  end
61
77
 
62
78
  # Returns LOGICAL keys (the app's key namespace stripped back off), so a
63
79
  # caller can feed any result straight back into download/delete/url.
64
- def list(prefix: nil, max: 1000)
80
+ # Trashed objects are deleted objects, so trash/ keys are left out unless
81
+ # include_trash: true. The filter runs after S3's max_keys, so a listing
82
+ # with trash in range can return fewer than max keys.
83
+ def list(prefix: nil, max: 1000, include_trash: false)
65
84
  resp = client.list_objects_v2(bucket: bucket, prefix: full_key(prefix), max_keys: max)
66
- resp.contents.map { |object| logical_key(object.key) }
85
+ keys = resp.contents.map(&:key)
86
+ keys = keys.reject { |key| Trash.trash_key?(key) } unless include_trash
87
+ keys.map { |key| logical_key(key) }
88
+ end
89
+
90
+ # Whether this process is real production, by the SAME resolution that
91
+ # picks the bucket half (QA_ENV first, then Rails.env). Public because the
92
+ # Active Storage trash service guards on it too.
93
+ def production_environment?
94
+ environment == "production"
95
+ end
96
+
97
+ # A non-production process (a laptop, a QA app, CI) may never delete from
98
+ # a bucket named "*-production", whoever handed it the bucket name or the
99
+ # key. Studio::S3 derives its bucket from the same environment, so here it
100
+ # is a backstop; for an Active Storage service, whose bucket comes from
101
+ # storage.yml, it is the guard.
102
+ def guard_production_bucket!(bucket_name)
103
+ return unless bucket_name.to_s.end_with?("-production")
104
+ return if production_environment?
105
+
106
+ raise Trash::ProductionBucketRefused,
107
+ "refusing to delete from #{bucket_name}: this process resolves to a non-production " \
108
+ "environment (#{deletion_environment}); only production deletes production objects"
109
+ end
110
+
111
+ # The environment a trash copy records in its deleted-env metadata:
112
+ # "qa" for a QA app (which runs Rails as production), else Rails.env.
113
+ def deletion_environment
114
+ return "qa" if EnvironmentBanner.qa_environment?
115
+ return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
116
+
117
+ "unknown"
67
118
  end
68
119
 
69
120
  def bucket
@@ -190,3 +241,5 @@ module Studio
190
241
  end
191
242
  end
192
243
  end
244
+
245
+ require_relative "s3/trash"
@@ -1,3 +1,3 @@
1
1
  module Studio
2
- VERSION = "0.84.0"
2
+ VERSION = "0.85.0"
3
3
  end
data/lib/studio.rb CHANGED
@@ -17,6 +17,7 @@ require "studio/site_footer"
17
17
  require "studio/booking"
18
18
  require "studio/navbar_links"
19
19
  require "studio/navbar_identity"
20
+ require "studio/auth_labels"
20
21
  require "studio/profile_sections"
21
22
  require "studio/profile_image"
22
23
  require "studio/oauth_identity"
@@ -201,6 +202,7 @@ module Studio
201
202
  # The name the signed-in user nav prints, and the signed-out button's label.
202
203
  # Both defaults render the navbar byte-identical to before. The engine has no
203
204
  # I18n catalogue, so the label is plain config rather than a locale key.
205
+ # The label also words the engine's auth pages (lib/studio/auth_labels.rb).
204
206
  # Rules: lib/studio/navbar_identity.rb.
205
207
  #
206
208
  # config.navbar_user_name = :player_name # a method on the user
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Find and restore objects Studio::S3.delete or the StudioTrashS3 Active Storage
4
+ # service moved under trash/. The bucket's lifecycle rule expires trash/ after
5
+ # three days; after that there is nothing to restore.
6
+ #
7
+ # bin/rails "studio:trash:list" # everything in trash
8
+ # bin/rails "studio:trash:list[avatars/abc.png]" # one key's trash copies
9
+ # bin/rails "studio:trash:restore[trash/2026-10-01/1759302000123/avatars/abc.png]"
10
+ #
11
+ # The bucket is Studio::S3's. SERVICE=<storage.yml service name> reads an Active
12
+ # Storage service's bucket instead (e.g. SERVICE=r2_production). FORCE=1 lets a
13
+ # restore overwrite an object that already sits at the original key.
14
+ #
15
+ # Logic lives in Studio::S3::Trash (unit-tested); these tasks only resolve the
16
+ # bucket and print.
17
+ # Guarded like studio_email.rake and studio_ses.rake: an app that also loads
18
+ # this file must not register a second copy of each action.
19
+ unless Rake::Task.task_defined?("studio:trash:restore")
20
+ namespace :studio do
21
+ namespace :trash do
22
+ def studio_trash_target
23
+ name = ENV["SERVICE"].to_s.strip
24
+ if name.empty?
25
+ { client: Studio::S3.client, bucket: Studio::S3.bucket, key_prefix: Studio::S3.key_prefix }
26
+ else
27
+ service = ActiveStorage::Blob.services.fetch(name.to_sym)
28
+ { client: service.client.client, bucket: service.bucket.name, key_prefix: "" }
29
+ end
30
+ end
31
+
32
+ desc "List trashed objects, optionally only those deleted from KEY"
33
+ task :list, [:key] => :environment do |_task, args|
34
+ target = studio_trash_target
35
+ key = args[:key].to_s.strip
36
+ # A Studio::S3 caller thinks in LOGICAL keys; the trash records the real
37
+ # one. Match either, so the key you passed to delete finds its copy.
38
+ wanted = key.empty? ? nil : [key, "#{target[:key_prefix]}#{key}"].uniq
39
+ objects = Studio::S3::Trash.list(client: target[:client], bucket: target[:bucket])
40
+ objects = objects.select { |object| wanted.include?(Studio::S3::Trash.original_key(object.key)) } if wanted
41
+
42
+ puts "#{objects.size} trashed object(s) in #{target[:bucket]}#{" for #{key}" if wanted}"
43
+ objects.each do |object|
44
+ puts "#{object.key} #{object.size} bytes #{object.last_modified&.utc&.iso8601}"
45
+ end
46
+ end
47
+
48
+ desc "Copy TRASH_KEY back to its original key (FORCE=1 overwrites)"
49
+ task :restore, [:trash_key] => :environment do |_task, args|
50
+ trash_key = args[:trash_key].to_s.strip
51
+ abort "usage: studio:trash:restore[trash/<date>/<ms>/<key>]" if trash_key.empty?
52
+
53
+ target = studio_trash_target
54
+ overwrite = %w[1 true yes].include?(ENV["FORCE"].to_s.downcase)
55
+ begin
56
+ original = Studio::S3::Trash.restore!(client: target[:client], bucket: target[:bucket],
57
+ trash_key: trash_key, overwrite: overwrite)
58
+ attributes = Studio::S3::Trash.blob_attributes(client: target[:client], bucket: target[:bucket],
59
+ trash_key: trash_key)
60
+ rescue Studio::S3::Error => error
61
+ abort "studio:trash:restore: #{error.message}"
62
+ end
63
+
64
+ puts "Restored #{target[:bucket]}/#{original}"
65
+ puts
66
+ puts "If this was an Active Storage object, its blob row is gone. Recreate it and re-attach:"
67
+ puts
68
+ puts " blob = ActiveStorage::Blob.create!("
69
+ puts " key: #{attributes[:key].inspect},"
70
+ puts " filename: #{(attributes[:filename] || 'FILENAME').inspect},"
71
+ puts " content_type: #{attributes[:content_type].inspect},"
72
+ puts " byte_size: #{attributes[:byte_size]},"
73
+ puts " checksum: #{attributes[:checksum].inspect},"
74
+ puts " service_name: #{(ENV['SERVICE'].to_s.strip.empty? ? 'SERVICE_NAME' : ENV['SERVICE'].strip).inspect}"
75
+ puts " )"
76
+ puts " record.photo.attach(blob) # the record and attachment it belonged to"
77
+ puts
78
+ puts "A nil checksum means a multipart upload, whose ETag is not an MD5. Compute it"
79
+ puts "from the restored bytes: Digest::MD5.base64digest(<downloaded bytes>)."
80
+ end
81
+ end
82
+ end
83
+ end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: studio-engine
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.84.0
4
+ version: 0.85.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alex McRitchie
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-10-01 00:00:00.000000000 Z
11
+ date: 2026-10-03 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -584,9 +584,11 @@ files:
584
584
  - db/migrate/20260902000001_create_studio_knowledge_expectations.rb
585
585
  - db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
586
586
  - db/migrate/20260930120000_create_studio_site_identities.rb
587
+ - lib/active_storage/service/studio_trash_s3_service.rb
587
588
  - lib/generators/studio/site_identity/site_identity_generator.rb
588
589
  - lib/studio-engine.rb
589
590
  - lib/studio.rb
591
+ - lib/studio/auth_labels.rb
590
592
  - lib/studio/booking.rb
591
593
  - lib/studio/cable.rb
592
594
  - lib/studio/color_scale.rb
@@ -617,6 +619,7 @@ files:
617
619
  - lib/studio/public_user.rb
618
620
  - lib/studio/redis.rb
619
621
  - lib/studio/s3.rb
622
+ - lib/studio/s3/trash.rb
620
623
  - lib/studio/session_fingerprint.rb
621
624
  - lib/studio/session_state.rb
622
625
  - lib/studio/sidebar_sections.rb
@@ -627,6 +630,7 @@ files:
627
630
  - lib/studio/version.rb
628
631
  - lib/tasks/studio_email.rake
629
632
  - lib/tasks/studio_ses.rake
633
+ - lib/tasks/studio_trash.rake
630
634
  - studio-engine.gemspec
631
635
  - tailwind/studio.tailwind.config.js
632
636
  homepage: https://github.com/McRitchie-Studio/studio-engine