studio-engine 0.83.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 +126 -0
- data/README.md +157 -3
- data/app/controllers/magic_links_controller.rb +1 -1
- data/app/controllers/registrations_controller.rb +1 -1
- data/app/helpers/studio/site_footer_helper.rb +18 -10
- 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/app/views/studio/booking/_assets.html.erb +43 -9
- data/app/views/studio/booking/_frame.html.erb +12 -9
- data/app/views/studio/bookings/show.html.erb +4 -2
- data/app/views/studio/site_footer/_assets.html.erb +33 -10
- data/app/views/studio/site_footer/_footer.html.erb +4 -3
- data/app/views/studio/site_footer/_link.html.erb +3 -1
- data/app/views/style/_site_footer.html.erb +35 -12
- data/lib/active_storage/service/studio_trash_s3_service.rb +64 -0
- data/lib/studio/auth_labels.rb +72 -0
- data/lib/studio/booking.rb +164 -4
- data/lib/studio/s3/trash.rb +248 -0
- data/lib/studio/s3.rb +56 -3
- data/lib/studio/site_footer.rb +63 -7
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +45 -5
- 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,132 @@ 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
|
+
|
|
52
|
+
## 0.84.0 — 2026-10-01
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **BREAKING for a 0.83.0 consumer that shows the booking frame: the frame is no
|
|
57
|
+
longer cropped by default.** 0.83.0 cropped every `studio_booking_frame` to a
|
|
58
|
+
414px window, 205px down a 732px frame. Those three numbers were measured from
|
|
59
|
+
one Google schedule, and on a schedule with a different header they sliced the
|
|
60
|
+
heading and exposed blank space. A crop that fits one schedule must not be a
|
|
61
|
+
default, so the frame now shows Google's whole page at rest until the app
|
|
62
|
+
declares its own crop. To keep exactly what 0.83.0 drew, add:
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
config.booking_crop = { top: 211, bottom: 613, frame_height: 732 }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Better, measure the app's own schedule (below): 613 was that schedule's box
|
|
69
|
+
with five slots showing, and its fullest day has eight, so the 0.83.0 window
|
|
70
|
+
cut off the last three. `crop: false` still shows the whole frame; `crop:
|
|
71
|
+
true`, the default, now means "the app's crop". See *Upgrading from 0.83.0* in
|
|
72
|
+
[`docs/BOOKING.md`](docs/BOOKING.md).
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
|
|
76
|
+
- **`config.booking_crop`: the booking frame's crop, per site.** A Hash of
|
|
77
|
+
`top:`, `bottom:` and `frame_height:`, in pixels of Google's own page: the
|
|
78
|
+
"Select an appointment time" box's top, its bottom on its fullest day, and the
|
|
79
|
+
page's height that day. The window is that box plus 6px on each edge. Because
|
|
80
|
+
`bottom` is the tallest the box gets, a day with fewer slots shows a strip of
|
|
81
|
+
Google's credit lines under a shorter box and never a cut one.
|
|
82
|
+
`studio_booking_frame crop:` takes `false`, `true` (the app's crop) or a Hash
|
|
83
|
+
for one frame. The numbers ride on each wrapper as custom properties, so two
|
|
84
|
+
frames with different crops share a page. The engine's `/schedule` honours the
|
|
85
|
+
app's crop; the popup is never cropped. A crop that cannot be one (not numbers,
|
|
86
|
+
negative, bottom not below top or outside the frame, a window as tall as the
|
|
87
|
+
frame) shows the frame whole and is logged once.
|
|
88
|
+
- **`bin/booking-crop-measure <booking url>`** prints that Hash. It opens Google's
|
|
89
|
+
embedded page at two widths, selects every bookable day of the next three
|
|
90
|
+
months, and reports the month-grid rows and the slot rows of the fullest day it
|
|
91
|
+
measured against. It reaches the live Google page, so it refuses to run in CI.
|
|
92
|
+
- **`config.booking_path`: an app's own booking page.** A path, or a callable
|
|
93
|
+
receiving the view. An app that renders `studio_booking_frame` in its own view,
|
|
94
|
+
rather than drawing the engine's `/schedule`, names that page here and gets
|
|
95
|
+
what the engine's page had: a footer link to it opens the popup, it is
|
|
96
|
+
`studio_booking_link`'s fallback href, and a signed-in viewer keeps the footer
|
|
97
|
+
on it. With neither set, nothing changes. It must be a local path (one leading
|
|
98
|
+
`/`): a scheme or a protocol-relative `//host` is refused, from a String at
|
|
99
|
+
assignment and from a callable on each request.
|
|
100
|
+
- [`docs/BOOKING.md`](docs/BOOKING.md): booking as a primitive of its own,
|
|
101
|
+
adoptable without the footer. Setting the schedule up in Google, sharing other
|
|
102
|
+
calendars in for conflict checking, the booking URL, measuring the crop, CSP,
|
|
103
|
+
and the limits. [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) keeps the footer.
|
|
104
|
+
- `/admin/style` shows the frame's two states, whole and cropped, as drawings.
|
|
105
|
+
|
|
106
|
+
### Fixed
|
|
107
|
+
|
|
108
|
+
- **A footer link column no longer breaks an email address or a URL mid-word.**
|
|
109
|
+
The link columns were equal `minmax(0, 1fr)` tracks with `overflow-wrap:
|
|
110
|
+
anywhere`, so an address in a column broke in two at every width. The rows are
|
|
111
|
+
unchanged (two link columns to a row on a phone, one row from 768px, beside the
|
|
112
|
+
brand from 1024px), but a track is now a plain `fr`, at least as wide as its
|
|
113
|
+
longest word, and an address is kept on one line: its column grows to hold it
|
|
114
|
+
and its neighbours give way. The first column is 1.5 shares by default and a
|
|
115
|
+
column may set its own, `[ "Contact", links, { width: 2 } ]`. More than four
|
|
116
|
+
columns wrap four to a row under the brand. An app that overrode 0.83.0's
|
|
117
|
+
tracks from its own stylesheet may leave that block (it draws the same rows) or
|
|
118
|
+
delete it once it is on this version; while it stays, `width:` hints do nothing.
|
|
119
|
+
[`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md) lists the stable class hooks.
|
|
120
|
+
- **A booking link that jumps to an inline frame asks Google once.** When the
|
|
121
|
+
frame had never been scrolled near, the link assigned its `src` and the
|
|
122
|
+
still-armed observer assigned it again as the frame scrolled into view: two
|
|
123
|
+
requests for one calendar. Whoever comes second now finds it set.
|
|
124
|
+
- **The footer and the booking note set their own line heights.** The Location
|
|
125
|
+
heading inherited the page's (a 45px line box at 1.5; it is 2.25rem, 36px,
|
|
126
|
+
now), and the legal line, the © line and the "Scheduling by Google Calendar"
|
|
127
|
+
note were each a pixel taller per line than their 1.25rem.
|
|
128
|
+
- **A second cropped frame on a page opens when focus moves into it from the
|
|
129
|
+
first.** Focus that goes from one frame straight into another never passes
|
|
130
|
+
through the window, so no second `blur` fired and the second frame stayed
|
|
131
|
+
cropped under the cursor. Only a page with two cropped frames could show it.
|
|
132
|
+
|
|
7
133
|
## 0.83.0 — 2026-10-01
|
|
8
134
|
|
|
9
135
|
### Added
|
data/README.md
CHANGED
|
@@ -41,7 +41,8 @@ resolved.
|
|
|
41
41
|
- **Site identity and link previews**: `Studio::SiteIdentity` holds the app's title, description and image, edited at `/admin/link_preview` beside a live unfurl card and read anywhere through `Studio.site_identity`. Every page unfurls with it unless it calls `link_preview image:, title:, description:`, and `Studio::LinkPreviewBots` serves preview fetchers a slim page under iMessage's 1 MiB limit. Adopt with `bin/rails g studio:site_identity`. See [`docs/LINK_PREVIEW.md`](docs/LINK_PREVIEW.md).
|
|
42
42
|
- **Public user page**: `/u/:username` shows a user's avatar and username (nothing else), and unfurls with their avatar, falling back to the site image. Link any username to it with `link_to_user_profile(user)` or `studio_user_profile_path(user)`. Opt in with `config.draw_public_user_routes = true`. See [`docs/PUBLIC_USER_PAGE.md`](docs/PUBLIC_USER_PAGE.md).
|
|
43
43
|
- **Transactional emails**: `Studio::EmailCatalog` — every email an app sends, its type, a live preview, and its banner — plus the shared `/admin/emails` page. Every app inherits the standard emails and their artwork on day one, and can register its own workflows and upload its own banners. See [Transactional emails](#transactional-emails).
|
|
44
|
-
- **Site footer
|
|
44
|
+
- **Site footer**: `<%= studio_site_footer %>` in the layout renders the public site's footer from `config.site_footer` (brand, social profiles, link columns, an address with a live map, legal links), every part optional. Leaflet is vendored and served by the engine. See [`docs/SITE_FOOTER.md`](docs/SITE_FOOTER.md).
|
|
45
|
+
- **Booking**: a simple scheduler on Google Calendar appointment schedules, with or without the footer. With `config.booking_url`, `studio_booking_frame` embeds Google's booking page, `studio_booking_link` opens it in a popup, and `config.draw_booking_routes = true` draws `/schedule` (or name the app's own page with `config.booking_path`). `config.booking_crop` crops the frame to the slot picker at rest, from numbers `bin/booking-crop-measure` reads off the app's own schedule. See [`docs/BOOKING.md`](docs/BOOKING.md).
|
|
45
46
|
|
|
46
47
|
## Configuration
|
|
47
48
|
|
|
@@ -126,7 +127,7 @@ This draws the enabled auth routes (`/login`, `/signup`, `/logout`, `POST /magic
|
|
|
126
127
|
|
|
127
128
|
Set `Studio.draw_session_routes = true` to add the session-drift rehydrate endpoint (`GET /session/state`) — off by default because it inherits the host's filters, which an app should check first ([`docs/SESSION_DRIFT.md`](docs/SESSION_DRIFT.md)).
|
|
128
129
|
|
|
129
|
-
Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one ([`docs/
|
|
130
|
+
Set `Studio.draw_booking_routes = true` to add the booking page (`GET /schedule`, helper `studio_booking_path`) — off by default because mcritchie-studio draws its own `/schedule` until it adopts this one. An app that keeps its own booking page names it with `Studio.booking_path` instead ([`docs/BOOKING.md`](docs/BOOKING.md)).
|
|
130
131
|
|
|
131
132
|
**Magic links need the `studio_links` table.** Install it with `bin/rails studio_engine:install:migrations && bin/rails db:migrate` (install all of them) before enabling `:magic_link` — never by hand-copying the migration, which collides with the task's own copy on `class CreateStudioLinks`. Without the table, the first sign-in raises `Studio::Link::MissingTable`.
|
|
132
133
|
|
|
@@ -628,7 +629,8 @@ Studio.configure do |config|
|
|
|
628
629
|
config.navbar_user_name = :player_name # a method on the user
|
|
629
630
|
# config.navbar_user_name = ->(user, view) { user.player_name } # or a callable
|
|
630
631
|
|
|
631
|
-
# 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).
|
|
632
634
|
config.sign_in_label = "Sign in" # default "Log in"
|
|
633
635
|
end
|
|
634
636
|
```
|
|
@@ -645,6 +647,24 @@ end
|
|
|
645
647
|
- The engine has no I18n catalogue, so the label is plain config, not a locale
|
|
646
648
|
key. A blank or non-String label, or a `navbar_user_name` of any other type,
|
|
647
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.
|
|
648
668
|
|
|
649
669
|
### User nav slots
|
|
650
670
|
|
|
@@ -919,6 +939,9 @@ set as a pair. R2 serves nothing anonymously from its S3 endpoint, so with an
|
|
|
919
939
|
endpoint and no `s3_public_url`, `Studio::S3.url` raises `NotConfigured` and
|
|
920
940
|
`upload` writes but returns `nil`; serve private objects with `signed_url`.
|
|
921
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
|
+
|
|
922
945
|
An app with **no** bucket configured does not error — `/admin/emails` renders
|
|
923
946
|
read-only, showing the inherited defaults it is genuinely sending and naming the
|
|
924
947
|
one setting that turns uploads on.
|
|
@@ -931,6 +954,137 @@ Add it to the app's admin sidebar section:
|
|
|
931
954
|
{ label: "Emails", href: admin_emails_path, emoji: "✉️", desc: "Transactional email banners" }
|
|
932
955
|
```
|
|
933
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
|
+
|
|
934
1088
|
## Overriding Views
|
|
935
1089
|
|
|
936
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)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Studio
|
|
4
4
|
# The site footer and the booking primitives, as view helpers. See
|
|
5
|
-
# docs/SITE_FOOTER.md.
|
|
5
|
+
# docs/SITE_FOOTER.md and docs/BOOKING.md.
|
|
6
6
|
#
|
|
7
7
|
# <%= studio_site_footer %> the footer, where it should show
|
|
8
8
|
# <%= studio_booking_frame %> Google's booking page, inline
|
|
@@ -40,8 +40,8 @@ module Studio
|
|
|
40
40
|
|
|
41
41
|
# Where the footer shows: Studio.site_footer_visible, asked of this view. By
|
|
42
42
|
# default that is every page for a visitor and, for a signed-in viewer, only
|
|
43
|
-
# the controllers in Studio.site_footer_controllers (plus the
|
|
44
|
-
#
|
|
43
|
+
# the controllers in Studio.site_footer_controllers (plus the booking page).
|
|
44
|
+
# Always false for an app that declared no footer.
|
|
45
45
|
def studio_show_site_footer?
|
|
46
46
|
return false if studio_site_footer_facts.nil?
|
|
47
47
|
|
|
@@ -75,12 +75,11 @@ module Studio
|
|
|
75
75
|
def studio_booking_embed_url = Studio::Booking.embed_url(Studio.booking_url)
|
|
76
76
|
|
|
77
77
|
# Where a booking link goes when the popup cannot open (scripts off, a
|
|
78
|
-
# modified click, a page with no dialog): the app's booking page
|
|
79
|
-
# engine
|
|
78
|
+
# modified click, a page with no dialog): the app's booking page
|
|
79
|
+
# (Studio.booking_path, or the engine's /schedule when drawn), else Google's
|
|
80
|
+
# own page.
|
|
80
81
|
def studio_booking_fallback_path
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
Studio.booking_url
|
|
82
|
+
Studio.booking_path_for(self) || Studio.booking_url
|
|
84
83
|
end
|
|
85
84
|
|
|
86
85
|
# The frame's accessible name.
|
|
@@ -99,11 +98,20 @@ module Studio
|
|
|
99
98
|
studio_site_footer_facts&.dig(:name) || Studio.site_identity[:title]
|
|
100
99
|
end
|
|
101
100
|
|
|
102
|
-
# The
|
|
101
|
+
# The crop a frame renders with: { offset:, window:, frame_height: }, or nil
|
|
102
|
+
# for the whole frame at rest. `true` reads Studio.booking_crop; a Hash is a
|
|
103
|
+
# one-off; false or nil is no crop.
|
|
104
|
+
def studio_booking_crop(crop = true)
|
|
105
|
+
Studio::Booking.crop(crop == true ? Studio.booking_crop : crop)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The inline booking frame. `crop:` is true (the default: use
|
|
109
|
+
# Studio.booking_crop, which is no crop until the app declares one), false
|
|
110
|
+
# (the whole page at rest) or a Hash for this frame alone.
|
|
103
111
|
def studio_booking_frame(title: nil, crop: true)
|
|
104
112
|
return unless studio_booking?
|
|
105
113
|
|
|
106
|
-
render "studio/booking/frame", title: studio_booking_title(title), crop: crop
|
|
114
|
+
render "studio/booking/frame", title: studio_booking_title(title), crop: studio_booking_crop(crop)
|
|
107
115
|
end
|
|
108
116
|
|
|
109
117
|
# The booking dialog, once per page however often it is asked for. The
|
|
@@ -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 %>
|
|
@@ -10,18 +10,22 @@
|
|
|
10
10
|
/* A booking link on this page scrolls here; land below the pinned navbar. */
|
|
11
11
|
scroll-margin-top: calc(var(--nav-bottom, 4rem) + 1rem); }
|
|
12
12
|
.booking-frame iframe { display: block; width: 100%; border: 0; height: 1200px; }
|
|
13
|
+
/* From 640px up Google lays the month beside the slots. A cropped wrapper
|
|
14
|
+
carries its own numbers (Studio::Booking.crop_style): how far the frame is
|
|
15
|
+
pulled up, the window's height, and the frame's full height. A wrapper with
|
|
16
|
+
no crop carries none, and shows the frame whole at the default height. */
|
|
13
17
|
@media (min-width: 640px) {
|
|
14
|
-
.booking-frame iframe { height: 732px; }
|
|
15
|
-
.booking-frame-cropped { height:
|
|
16
|
-
.booking-frame-cropped iframe { margin-top: -
|
|
17
|
-
.booking-frame-cropped.is-open { height: 732px; }
|
|
18
|
+
.booking-frame iframe { height: var(--booking-frame-height, 732px); }
|
|
19
|
+
.booking-frame-cropped { height: var(--booking-crop-window); transition: height .25s ease; }
|
|
20
|
+
.booking-frame-cropped iframe { margin-top: calc(-1 * var(--booking-crop-offset)); transition: margin-top .25s ease; }
|
|
21
|
+
.booking-frame-cropped.is-open { height: var(--booking-frame-height, 732px); }
|
|
18
22
|
.booking-frame-cropped.is-open iframe { margin-top: 0; }
|
|
19
23
|
}
|
|
20
24
|
/* Scripts off: the frame never gets its src, so it is hidden and a plain link
|
|
21
25
|
stands in its place (the <noscript> in studio/booking/_frame). */
|
|
22
26
|
.booking-frame-noscript { margin: 0; padding: 3rem 1.5rem; text-align: center; color: #111; }
|
|
23
27
|
.booking-frame-noscript a { color: #4338ca; font-weight: 700; text-decoration: underline; }
|
|
24
|
-
.booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; color: var(--color-text-muted, inherit); }
|
|
28
|
+
.booking-frame-note { margin: 1rem 0 0; text-align: center; font-size: .875rem; line-height: 1.25rem; color: var(--color-text-muted, inherit); }
|
|
25
29
|
.booking-frame-note a { color: var(--color-primary, #8b5cf6); }
|
|
26
30
|
.booking-frame-note a:hover { text-decoration: underline; }
|
|
27
31
|
|
|
@@ -69,15 +73,23 @@
|
|
|
69
73
|
if (window.__studioBookingFramesArmed) return;
|
|
70
74
|
window.__studioBookingFramesArmed = true;
|
|
71
75
|
|
|
76
|
+
// ASK GOOGLE ONCE. Two things can ask for a frame: scrolling near it, and a
|
|
77
|
+
// booking link that jumps to it. Assigning src a second time, even to the
|
|
78
|
+
// same URL, is a second request, so whoever comes second finds it set.
|
|
79
|
+
function load(frame) {
|
|
80
|
+
if (!frame.getAttribute('src')) frame.src = frame.dataset.src;
|
|
81
|
+
}
|
|
82
|
+
window.__studioBookingLoad = load;
|
|
83
|
+
|
|
72
84
|
function arm() {
|
|
73
85
|
document.querySelectorAll('iframe[data-booking-frame][data-studio-booking][data-src]').forEach(function (frame) {
|
|
74
86
|
if (frame.__bookingArmed) return;
|
|
75
87
|
frame.__bookingArmed = true;
|
|
76
|
-
if (typeof IntersectionObserver !== 'function') { frame
|
|
88
|
+
if (typeof IntersectionObserver !== 'function') { load(frame); return; }
|
|
77
89
|
var watcher = new IntersectionObserver(function (entries) {
|
|
78
90
|
if (!entries.some(function (entry) { return entry.isIntersecting; })) return;
|
|
79
91
|
watcher.disconnect();
|
|
80
|
-
frame
|
|
92
|
+
load(frame);
|
|
81
93
|
}, { rootMargin: '200px' });
|
|
82
94
|
watcher.observe(frame);
|
|
83
95
|
});
|
|
@@ -90,12 +102,34 @@
|
|
|
90
102
|
|
|
91
103
|
// Focus moving into the frame is the one signal a cross-origin frame gives
|
|
92
104
|
// its parent. It means the visitor has started using the calendar.
|
|
93
|
-
|
|
105
|
+
function openActive() {
|
|
94
106
|
var active = document.activeElement;
|
|
95
107
|
if (!active || !active.matches || !active.matches('iframe[data-booking-frame][data-studio-booking]')) return;
|
|
96
108
|
var wrap = active.closest('[data-booking-wrap][data-studio-booking]');
|
|
97
109
|
if (wrap) wrap.classList.add('is-open');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// THE WINDOW BLURS ONCE. Focus that moves from one frame straight into
|
|
113
|
+
// another never comes back through this window, so no second blur announces
|
|
114
|
+
// the second frame, and on a page with two cropped frames it would stay
|
|
115
|
+
// cropped under the visitor's cursor. So while focus is away in a frame and a
|
|
116
|
+
// cropped wrapper is still shut, look again a few times a second. It stops
|
|
117
|
+
// when focus returns here or nothing is left to open, and a page with one
|
|
118
|
+
// frame never starts it: that frame is open by then.
|
|
119
|
+
var watching = null;
|
|
120
|
+
function shut() { return document.querySelector('[data-booking-wrap][data-studio-booking][data-booking-crop]:not(.is-open)'); }
|
|
121
|
+
function stopWatching() { if (watching) { clearInterval(watching); watching = null; } }
|
|
122
|
+
|
|
123
|
+
window.addEventListener('blur', function () {
|
|
124
|
+
openActive();
|
|
125
|
+
var active = document.activeElement;
|
|
126
|
+
if (watching || !active || active.tagName !== 'IFRAME' || !shut()) return;
|
|
127
|
+
watching = setInterval(function () {
|
|
128
|
+
openActive();
|
|
129
|
+
if (!shut()) stopWatching();
|
|
130
|
+
}, 250);
|
|
98
131
|
});
|
|
132
|
+
window.addEventListener('focus', stopWatching);
|
|
99
133
|
|
|
100
134
|
document.addEventListener('turbo:load', armAfterLoad);
|
|
101
135
|
armAfterLoad();
|
|
@@ -127,7 +161,7 @@
|
|
|
127
161
|
var wrap = inline.closest('[data-booking-wrap][data-studio-booking]');
|
|
128
162
|
if (wrap) wrap.classList.add('is-open');
|
|
129
163
|
// Before `load` the frame keeps waiting: its own script assigns src then.
|
|
130
|
-
if (
|
|
164
|
+
if (document.readyState === 'complete' && window.__studioBookingLoad) window.__studioBookingLoad(inline);
|
|
131
165
|
var calm = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
132
166
|
(wrap || inline).scrollIntoView({ behavior: calm ? 'auto' : 'smooth', block: 'start' });
|
|
133
167
|
inline.focus({ preventScroll: true });
|
|
@@ -4,17 +4,20 @@
|
|
|
4
4
|
width: month beside the slots from about 600px, stacked below that. It
|
|
5
5
|
cannot be themed, so it sits on a white panel in both themes.
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
WHOLE AT REST unless the app declares a crop (Studio.booking_crop, or
|
|
8
|
+
`crop:` on the call). `crop` here is the resolved crop, a Hash of offset,
|
|
9
|
+
window and frame_height, or nil.
|
|
10
|
+
|
|
11
|
+
CROPPED AT REST, WHOLE IN USE, when it has one. From 640px up the wrapper
|
|
12
|
+
shows only the "Select an appointment time" box: Google's header above it
|
|
13
|
+
and its credit lines below are clipped. Where that box sits belongs to one
|
|
14
|
+
schedule, so the numbers ride on the wrapper as custom properties and two
|
|
15
|
+
frames with different crops can share a page. The crop cannot stay on,
|
|
16
|
+
because the form that opens when a slot is picked is a dialog centred in the
|
|
17
|
+
frame's FULL height, so a cropped frame cuts off its title and its Book
|
|
13
18
|
button. A cross-origin frame reports no clicks, but the parent window does
|
|
14
19
|
lose focus to it; on that signal the wrapper opens to the full frame.
|
|
15
20
|
Below 640px Google stacks the month above the slots and nothing is cropped.
|
|
16
|
-
Those numbers are Google's layout, measured 2026-09-30; `crop: false` drops
|
|
17
|
-
the crop if that layout moves.
|
|
18
21
|
|
|
19
22
|
THE SRC IS DEFERRED, and not with loading="lazy". The frame is a third
|
|
20
23
|
party, and a frame that starts loading before the window's `load` event
|
|
@@ -25,7 +28,7 @@
|
|
|
25
28
|
WITH SCRIPTS OFF nothing ever assigns the src, so the <noscript> hides the
|
|
26
29
|
empty frame and puts a plain link to Google's booking page in its place. %>
|
|
27
30
|
<%= studio_booking_assets %>
|
|
28
|
-
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking
|
|
31
|
+
<div class="booking-frame<%= " booking-frame-cropped" if crop %>" data-booking-wrap data-studio-booking<% if crop %> data-booking-crop style="<%= Studio::Booking.crop_style(crop) %>"<% end %>>
|
|
29
32
|
<iframe data-booking-frame data-studio-booking
|
|
30
33
|
data-src="<%= studio_booking_embed_url %>"
|
|
31
34
|
title="<%= title %>"
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
<%# /schedule — the booking page (Studio::BookingsController), drawn by
|
|
2
2
|
Studio.draw_booking_routes. It is where a booking link lands when the popup
|
|
3
|
-
cannot open.
|
|
4
|
-
|
|
3
|
+
cannot open. The frame takes the app's crop (Studio.booking_crop), as it
|
|
4
|
+
does anywhere. An app that wants different words around the frame renders
|
|
5
|
+
`studio_booking_frame` in a view of its own instead, and names that page in
|
|
6
|
+
Studio.booking_path. %>
|
|
5
7
|
<% label = studio_booking_label %>
|
|
6
8
|
<% content_for(:title, "#{label} · #{studio_site_footer_name}") %>
|
|
7
9
|
<% content_for(:full_width, true) %>
|