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 +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +154 -1
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +1 -1
- data/app/views/registrations/new.html.erb +1 -1
- data/app/views/sessions/_sso_continue.html.erb +1 -1
- data/app/views/sessions/new.html.erb +6 -3
- data/app/views/studio/_confirm_interstitial.html.erb +1 -1
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +2 -0
- data/lib/tasks/studio_trash.rake +83 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 940823449254339d163e66fe93eabdab83cc36e25f4b80600ab02c6552e3d624
|
|
4
|
+
data.tar.gz: 1ce338c5ba90749bbd3a5217c5dba0bd3813b5be96996c15c734cdacdb1b0f0d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
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
|
|
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"
|
|
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"
|
|
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
|
-
|
|
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
|
|
77
|
+
Send <%= Studio::AuthLabels.link_noun(sign_in_label) %>
|
|
75
78
|
</button>
|
|
76
79
|
<% end %>
|
|
77
80
|
<% end %>
|
|
@@ -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
|
-
|
|
64
|
+
target = bucket
|
|
65
|
+
guard_production_bucket!(target)
|
|
66
|
+
Trash.trash!(client: client, bucket: target, key: full_key(key), env: deletion_environment)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The HARD delete, gone at once with no trash copy. For objects with no
|
|
70
|
+
# value after deletion (regenerable derivatives, a trash copy itself).
|
|
71
|
+
# The same production-bucket guard as delete.
|
|
72
|
+
def purge!(key:)
|
|
73
|
+
target = bucket
|
|
74
|
+
guard_production_bucket!(target)
|
|
75
|
+
client.delete_object(bucket: target, key: full_key(key))
|
|
60
76
|
end
|
|
61
77
|
|
|
62
78
|
# Returns LOGICAL keys (the app's key namespace stripped back off), so a
|
|
63
79
|
# caller can feed any result straight back into download/delete/url.
|
|
64
|
-
|
|
80
|
+
# Trashed objects are deleted objects, so trash/ keys are left out unless
|
|
81
|
+
# include_trash: true. The filter runs after S3's max_keys, so a listing
|
|
82
|
+
# with trash in range can return fewer than max keys.
|
|
83
|
+
def list(prefix: nil, max: 1000, include_trash: false)
|
|
65
84
|
resp = client.list_objects_v2(bucket: bucket, prefix: full_key(prefix), max_keys: max)
|
|
66
|
-
resp.contents.map
|
|
85
|
+
keys = resp.contents.map(&:key)
|
|
86
|
+
keys = keys.reject { |key| Trash.trash_key?(key) } unless include_trash
|
|
87
|
+
keys.map { |key| logical_key(key) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Whether this process is real production, by the SAME resolution that
|
|
91
|
+
# picks the bucket half (QA_ENV first, then Rails.env). Public because the
|
|
92
|
+
# Active Storage trash service guards on it too.
|
|
93
|
+
def production_environment?
|
|
94
|
+
environment == "production"
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# A non-production process (a laptop, a QA app, CI) may never delete from
|
|
98
|
+
# a bucket named "*-production", whoever handed it the bucket name or the
|
|
99
|
+
# key. Studio::S3 derives its bucket from the same environment, so here it
|
|
100
|
+
# is a backstop; for an Active Storage service, whose bucket comes from
|
|
101
|
+
# storage.yml, it is the guard.
|
|
102
|
+
def guard_production_bucket!(bucket_name)
|
|
103
|
+
return unless bucket_name.to_s.end_with?("-production")
|
|
104
|
+
return if production_environment?
|
|
105
|
+
|
|
106
|
+
raise Trash::ProductionBucketRefused,
|
|
107
|
+
"refusing to delete from #{bucket_name}: this process resolves to a non-production " \
|
|
108
|
+
"environment (#{deletion_environment}); only production deletes production objects"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# The environment a trash copy records in its deleted-env metadata:
|
|
112
|
+
# "qa" for a QA app (which runs Rails as production), else Rails.env.
|
|
113
|
+
def deletion_environment
|
|
114
|
+
return "qa" if EnvironmentBanner.qa_environment?
|
|
115
|
+
return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env) && Rails.env
|
|
116
|
+
|
|
117
|
+
"unknown"
|
|
67
118
|
end
|
|
68
119
|
|
|
69
120
|
def bucket
|
|
@@ -190,3 +241,5 @@ module Studio
|
|
|
190
241
|
end
|
|
191
242
|
end
|
|
192
243
|
end
|
|
244
|
+
|
|
245
|
+
require_relative "s3/trash"
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -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.
|
|
4
|
+
version: 0.85.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-03 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: rails
|
|
@@ -584,9 +584,11 @@ files:
|
|
|
584
584
|
- db/migrate/20260902000001_create_studio_knowledge_expectations.rb
|
|
585
585
|
- db/migrate/20260902000002_add_expectation_to_studio_knowledge_docs.rb
|
|
586
586
|
- db/migrate/20260930120000_create_studio_site_identities.rb
|
|
587
|
+
- lib/active_storage/service/studio_trash_s3_service.rb
|
|
587
588
|
- lib/generators/studio/site_identity/site_identity_generator.rb
|
|
588
589
|
- lib/studio-engine.rb
|
|
589
590
|
- lib/studio.rb
|
|
591
|
+
- lib/studio/auth_labels.rb
|
|
590
592
|
- lib/studio/booking.rb
|
|
591
593
|
- lib/studio/cable.rb
|
|
592
594
|
- lib/studio/color_scale.rb
|
|
@@ -617,6 +619,7 @@ files:
|
|
|
617
619
|
- lib/studio/public_user.rb
|
|
618
620
|
- lib/studio/redis.rb
|
|
619
621
|
- lib/studio/s3.rb
|
|
622
|
+
- lib/studio/s3/trash.rb
|
|
620
623
|
- lib/studio/session_fingerprint.rb
|
|
621
624
|
- lib/studio/session_state.rb
|
|
622
625
|
- lib/studio/sidebar_sections.rb
|
|
@@ -627,6 +630,7 @@ files:
|
|
|
627
630
|
- lib/studio/version.rb
|
|
628
631
|
- lib/tasks/studio_email.rake
|
|
629
632
|
- lib/tasks/studio_ses.rake
|
|
633
|
+
- lib/tasks/studio_trash.rake
|
|
630
634
|
- studio-engine.gemspec
|
|
631
635
|
- tailwind/studio.tailwind.config.js
|
|
632
636
|
homepage: https://github.com/McRitchie-Studio/studio-engine
|