studio-engine 0.135.2 → 0.136.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 +58 -11
- data/README.md +7 -0
- data/app/assets/javascripts/studio/cropper-1.6.2.min.js +10 -0
- data/app/assets/javascripts/studio/page_transition_reveal.js +3 -0
- data/app/assets/stylesheets/studio/cropper-1.6.2.min.css +9 -0
- data/app/controllers/concerns/studio/error_handling.rb +58 -1
- data/app/helpers/studio/page_header_helper.rb +26 -0
- data/app/helpers/studio_navbar_helper.rb +22 -0
- data/app/javascript/studio/application.js +1 -0
- data/app/javascript/studio/cropper.js +2 -2
- data/app/javascript/studio/page_header_self_styled.js +194 -0
- data/app/javascript/studio/page_transitions.js +42 -11
- data/app/javascript/studio/reload_visits.js +93 -0
- data/app/models/studio/user_session.rb +216 -21
- data/app/views/layouts/_navbar.html.erb +11 -4
- data/app/views/studio/_cropper_assets.html.erb +14 -9
- data/app/views/studio/_legal_page.html.erb +52 -0
- data/app/views/studio/_page_header.html.erb +12 -2
- data/app/views/studio/page_header/_assets.html.erb +151 -0
- data/db/migrate/20261108120000_add_device_revocation_to_studio_user_sessions.rb +28 -0
- data/lib/studio/engine.rb +6 -1
- data/lib/studio/host_schema.rb +53 -2
- data/lib/studio/page_header.rb +11 -2
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +10 -0
- metadata +9 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// studio/page_header_self_styled: the page-header controller's work for an app with no
|
|
2
|
+
// Stimulus and no import map (config.page_header_self_styled, docs/PAGE_HEADER.md, "In a
|
|
3
|
+
// plain-CSS app"). studio/page_header/_assets loads it by its own module tag, once per
|
|
4
|
+
// page, beside the row's scoped CSS.
|
|
5
|
+
//
|
|
6
|
+
// For every studio/_page_header on the page it:
|
|
7
|
+
// - marks the row data-stuck once it has left its place in the flow, so the CSS gives
|
|
8
|
+
// it the bar;
|
|
9
|
+
// - marks which ends of the tabs hide a tab (data-fade-start, data-fade-end);
|
|
10
|
+
// - scrolls the open tab into view inside the tabs on load;
|
|
11
|
+
// - keeps each Links menu's aria-expanded in step with its popover, walks into the list
|
|
12
|
+
// on ArrowDown, and places the list where the browser has no anchor positioning.
|
|
13
|
+
//
|
|
14
|
+
// With no pinned stack on the page (window.studioPinnedStack), it also publishes
|
|
15
|
+
// --nav-bottom from the document's first <header>, when that header is sticky or fixed,
|
|
16
|
+
// so the row sticks under the app's own masthead. An app whose CSS sets --nav-bottom or
|
|
17
|
+
// --pin-page-top keeps its own value.
|
|
18
|
+
//
|
|
19
|
+
// IT IMPORTS NOTHING, because a plain app may have no import map. The five measurements
|
|
20
|
+
// below are studio/page_header's, copied; test/javascript/page_header_self_styled.test.mjs
|
|
21
|
+
// holds the copies to the originals. It boots only in a browser, so the test can load it
|
|
22
|
+
// as a data: module. A module runs once per document, so a Turbo visit rescans on
|
|
23
|
+
// turbo:load.
|
|
24
|
+
|
|
25
|
+
export function isStuck(rowTop, restTop) {
|
|
26
|
+
return rowTop - restTop > 0.5
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function sentinelMarksThePlace(parentDisplay) {
|
|
30
|
+
return !/flex|grid/.test(parentDisplay)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function hiddenEnds({ scrollLeft, clientWidth, scrollWidth }) {
|
|
34
|
+
return {
|
|
35
|
+
start: scrollLeft > 1,
|
|
36
|
+
end: scrollLeft + clientWidth < scrollWidth - 1
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function revealBy(box, tab, margin = 32) {
|
|
41
|
+
if (tab.left < box.left) return tab.left - box.left - margin
|
|
42
|
+
if (tab.right > box.right) return tab.right - box.right + margin
|
|
43
|
+
return 0
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function menuPlacement(button, list, viewport, gap = 8) {
|
|
47
|
+
const below = viewport.height - button.bottom
|
|
48
|
+
const above = button.top
|
|
49
|
+
const top = below < list.height + gap && above > below
|
|
50
|
+
? Math.max(gap, button.top - list.height - 4)
|
|
51
|
+
: button.bottom + 4
|
|
52
|
+
const left = Math.min(
|
|
53
|
+
Math.max(gap, button.right - list.width),
|
|
54
|
+
Math.max(gap, viewport.width - list.width - gap)
|
|
55
|
+
)
|
|
56
|
+
return { top, left }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The bottom edge, in px, of the app's own masthead: the first header, when it is sticky
|
|
60
|
+
// or fixed. 0 for any other header, or none.
|
|
61
|
+
export function mastheadBottom(position, bottom) {
|
|
62
|
+
return position === "sticky" || position === "fixed" ? Math.max(0, bottom) : 0
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function anchorsSupported() {
|
|
66
|
+
return typeof CSS !== "undefined" && !!CSS.supports &&
|
|
67
|
+
CSS.supports("anchor-name: --a") && CSS.supports("position-anchor: --a")
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function boot() {
|
|
71
|
+
if (window.studioPageHeaderSelfStyled) {
|
|
72
|
+
window.studioPageHeaderSelfStyled.scan()
|
|
73
|
+
return
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const root = document.documentElement
|
|
77
|
+
let rows = []
|
|
78
|
+
let frame = null
|
|
79
|
+
let ownsNavBottom = null
|
|
80
|
+
|
|
81
|
+
const publishNavBottom = () => {
|
|
82
|
+
if (window.studioPinnedStack) return
|
|
83
|
+
if (ownsNavBottom === null) {
|
|
84
|
+
const computed = getComputedStyle(root)
|
|
85
|
+
ownsNavBottom = !root.style.getPropertyValue("--nav-bottom") &&
|
|
86
|
+
!computed.getPropertyValue("--nav-bottom").trim() &&
|
|
87
|
+
!computed.getPropertyValue("--pin-page-top").trim()
|
|
88
|
+
}
|
|
89
|
+
if (!ownsNavBottom) return
|
|
90
|
+
const header = document.querySelector("header")
|
|
91
|
+
const bottom = header ? mastheadBottom(getComputedStyle(header).position, header.getBoundingClientRect().bottom) : 0
|
|
92
|
+
const value = `${Math.round(bottom * 100) / 100}px`
|
|
93
|
+
if (root.style.getPropertyValue("--nav-bottom") !== value) root.style.setProperty("--nav-bottom", value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The top of the row's place in the flow. In a flex or grid parent only the row can
|
|
97
|
+
// say, so it is unstuck for one read; no frame paints in between.
|
|
98
|
+
const restTop = ({ sentinel, row }) => {
|
|
99
|
+
if (sentinelMarksThePlace(getComputedStyle(sentinel.parentElement).display)) {
|
|
100
|
+
return sentinel.getBoundingClientRect().top
|
|
101
|
+
}
|
|
102
|
+
row.style.position = "static"
|
|
103
|
+
const top = row.getBoundingClientRect().top
|
|
104
|
+
row.style.position = ""
|
|
105
|
+
return top
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const placeMenu = (button, list) => {
|
|
109
|
+
const place = menuPlacement(button.getBoundingClientRect(), list.getBoundingClientRect(),
|
|
110
|
+
{ width: window.innerWidth, height: window.innerHeight })
|
|
111
|
+
list.style.top = `${place.top}px`
|
|
112
|
+
list.style.left = `${place.left}px`
|
|
113
|
+
list.style.right = "auto"
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sync = () => {
|
|
117
|
+
publishNavBottom()
|
|
118
|
+
rows = rows.filter((item) => item.row.isConnected)
|
|
119
|
+
for (const item of rows) {
|
|
120
|
+
item.row.toggleAttribute("data-stuck", isStuck(item.row.getBoundingClientRect().top, restTop(item)))
|
|
121
|
+
if (!anchorsSupported()) {
|
|
122
|
+
for (const { button, list } of item.menus) {
|
|
123
|
+
if (list.matches(":popover-open")) placeMenu(button, list)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (!item.tabs) continue
|
|
127
|
+
const ends = hiddenEnds(item.tabs)
|
|
128
|
+
item.tabs.toggleAttribute("data-fade-start", ends.start)
|
|
129
|
+
item.tabs.toggleAttribute("data-fade-end", ends.end)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const schedule = () => {
|
|
134
|
+
if (frame) return
|
|
135
|
+
frame = requestAnimationFrame(() => {
|
|
136
|
+
frame = null
|
|
137
|
+
sync()
|
|
138
|
+
})
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A disclosure, not role="menu": the popover opens, closes on Escape and gives focus
|
|
142
|
+
// back with no script. This keeps aria-expanded honest and adds ArrowDown.
|
|
143
|
+
const wireMenu = (menu) => {
|
|
144
|
+
const button = menu.querySelector("[popovertarget]")
|
|
145
|
+
const list = menu.querySelector("[popover]")
|
|
146
|
+
if (!button || !list) return null
|
|
147
|
+
button.setAttribute("aria-expanded", "false")
|
|
148
|
+
list.addEventListener("toggle", (event) => {
|
|
149
|
+
const open = event.newState === "open"
|
|
150
|
+
button.setAttribute("aria-expanded", String(open))
|
|
151
|
+
if (open) {
|
|
152
|
+
if (!anchorsSupported()) placeMenu(button, list)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
if (document.activeElement === document.body || list.contains(document.activeElement)) button.focus()
|
|
156
|
+
})
|
|
157
|
+
button.addEventListener("keydown", (event) => {
|
|
158
|
+
if (event.key !== "ArrowDown") return
|
|
159
|
+
event.preventDefault()
|
|
160
|
+
if (!list.matches(":popover-open")) list.showPopover?.()
|
|
161
|
+
list.querySelector("a")?.focus()
|
|
162
|
+
})
|
|
163
|
+
return { button, list }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const wire = (sentinel) => {
|
|
167
|
+
if (sentinel.hasAttribute("data-page-header-live")) return
|
|
168
|
+
const row = sentinel.nextElementSibling
|
|
169
|
+
if (!row || !row.classList.contains("page-header")) return
|
|
170
|
+
sentinel.setAttribute("data-page-header-live", "")
|
|
171
|
+
const tabs = row.querySelector(".page-header-tabs")
|
|
172
|
+
const menus = [...row.querySelectorAll(".page-header-menu")].map(wireMenu).filter(Boolean)
|
|
173
|
+
if (tabs) {
|
|
174
|
+
tabs.addEventListener("scroll", schedule, { passive: true })
|
|
175
|
+
const open = tabs.querySelector("[aria-current]")
|
|
176
|
+
if (open) tabs.scrollLeft += revealBy(tabs.getBoundingClientRect(), open.getBoundingClientRect())
|
|
177
|
+
}
|
|
178
|
+
rows.push({ sentinel, row, tabs, menus })
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const scan = () => {
|
|
182
|
+
document.querySelectorAll(".page-header-sentinel").forEach(wire)
|
|
183
|
+
sync()
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
window.studioPageHeaderSelfStyled = { scan }
|
|
187
|
+
window.addEventListener("scroll", schedule, { passive: true })
|
|
188
|
+
window.addEventListener("resize", schedule, { passive: true })
|
|
189
|
+
document.addEventListener("turbo:load", scan)
|
|
190
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", scan)
|
|
191
|
+
else scan()
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (typeof window !== "undefined" && typeof document !== "undefined") boot()
|
|
@@ -106,6 +106,15 @@ export function leaveStyle(win, style) {
|
|
|
106
106
|
}
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
// A skip the engine chooses. Chrome rejects the skipped transition's `ready` with "Transition
|
|
110
|
+
// was skipped" and leaves the rejection unhandled (measured: a pageerror whenever nothing
|
|
111
|
+
// awaits `ready`). This skip is the engine's own decision, so that one rejection is expected
|
|
112
|
+
// and handled here. A transition the engine did not skip is never touched.
|
|
113
|
+
export function skipOwnTransition(transition) {
|
|
114
|
+
if (transition.ready && typeof transition.ready.catch === "function") transition.ready.catch(() => {})
|
|
115
|
+
transition.skipTransition()
|
|
116
|
+
}
|
|
117
|
+
|
|
109
118
|
// A slow image must not hold a click: past the cap the swap goes ahead.
|
|
110
119
|
export function withCap(promise, ms, timer = setTimeout) {
|
|
111
120
|
return Promise.race([promise, new Promise((done) => timer(done, ms))])
|
|
@@ -210,9 +219,11 @@ export function installPageTransitions(doc) {
|
|
|
210
219
|
|
|
211
220
|
doc.addEventListener("turbo:visit", (event) => {
|
|
212
221
|
root.dataset.pageTransitionVisit = ""
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
222
|
+
// A response Turbo will not render gets no turbo:before-render, so this stands. Turbo
|
|
223
|
+
// opens no transition for it (studio/reload_visits takes the meta out of what it parses),
|
|
224
|
+
// and should one open on a path that guard does not see (a frame's reload visit), this
|
|
225
|
+
// makes it draw nothing. The full load after it plays the one motion. Left alone while a
|
|
226
|
+
// motion is still running.
|
|
216
227
|
if (!root.dataset.pageTransition) root.dataset.pageTransition = STILL
|
|
217
228
|
target = new URL(event.detail.url).pathname
|
|
218
229
|
// A GET that redirects: Turbo renders the response, then replaces to the final address
|
|
@@ -231,17 +242,37 @@ export function installPageTransitions(doc) {
|
|
|
231
242
|
|
|
232
243
|
// A page never visited: read its names from the response, ahead of the old page's picture.
|
|
233
244
|
// Only a page with names of its own can share one, so every other page skips the parse.
|
|
245
|
+
//
|
|
246
|
+
// TURBO READS THE PAGE THROUGH THIS READ. Turbo starts its view transition one frame after
|
|
247
|
+
// its own read of the response resolves, and the browser takes the old page's picture then:
|
|
248
|
+
// the morph's name must be down by that moment. Turbo's responseHTML getter is a fresh
|
|
249
|
+
// response.clone().text() on each access, and nothing orders this module's read before
|
|
250
|
+
// Turbo's, so this read is put in its place (studio/reload_visits does the same): the read
|
|
251
|
+
// Turbo awaits is the one that has already named the morph. Measured without it: under an
|
|
252
|
+
// 8x CPU throttle, 2 of 6 visits started the transition with no name, and nothing morphed.
|
|
234
253
|
doc.addEventListener("turbo:before-fetch-response", (event) => {
|
|
235
254
|
if (event.target && event.target.tagName === "TURBO-FRAME") return
|
|
236
255
|
if (!morphNames(doc).size) return
|
|
237
256
|
const { fetchResponse } = event.detail
|
|
238
|
-
Promise.resolve(fetchResponse.responseHTML).then((html) => {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
257
|
+
const named = Promise.resolve(fetchResponse.responseHTML).then((html) => {
|
|
258
|
+
try {
|
|
259
|
+
if (!html) return html
|
|
260
|
+
const names = morphNames(new win.DOMParser().parseFromString(html, "text/html"))
|
|
261
|
+
const path = new URL(fetchResponse.response.url).pathname
|
|
262
|
+
known.set(path, names)
|
|
263
|
+
if (target && (path === target || fetchResponse.redirected)) predict(names)
|
|
264
|
+
} catch {
|
|
265
|
+
// A page this module cannot read morphs nothing; Turbo still renders it.
|
|
266
|
+
}
|
|
267
|
+
return html
|
|
268
|
+
})
|
|
269
|
+
// A failed read rejects for Turbo exactly as its own would; this module needs no answer.
|
|
270
|
+
named.catch(() => {})
|
|
271
|
+
try {
|
|
272
|
+
Object.defineProperty(fetchResponse, "responseHTML", { configurable: true, value: named })
|
|
273
|
+
} catch {
|
|
274
|
+
// A response that refuses the swap keeps Turbo's own read, and the race this closes.
|
|
275
|
+
}
|
|
245
276
|
})
|
|
246
277
|
|
|
247
278
|
// In capture, so the hold is in place before any other listener takes Turbo's resume.
|
|
@@ -305,7 +336,7 @@ export function installPageTransitions(doc) {
|
|
|
305
336
|
const left = leaveStyle(win, style)
|
|
306
337
|
if (!transition) return
|
|
307
338
|
if (!left) {
|
|
308
|
-
transition
|
|
339
|
+
skipOwnTransition(transition)
|
|
309
340
|
return
|
|
310
341
|
}
|
|
311
342
|
markHeld(doc)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// studio/reload_visits: Turbo opens no view transition for a visit it will only reload.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT. Smooth load (layouts/studio/_smooth_load) turns on Turbo's same-document view
|
|
4
|
+
// transitions. Turbo starts the transition BEFORE it reads the response's head: its render
|
|
5
|
+
// callback is what finds turbo-visit-control reload (turf-monster's /wallet and /account) or a
|
|
6
|
+
// changed data-turbo-track="reload" asset, and then navigates by a full load. That navigation
|
|
7
|
+
// can skip the transition while its `ready` promise is still pending. Turbo awaits only
|
|
8
|
+
// `finished` (turbo-rails 2.0.23, turbo.js ViewTransitioner#renderChange), so `ready` rejects
|
|
9
|
+
// with nobody listening: an unhandled "Transition was skipped" error on every such visit that
|
|
10
|
+
// loses the race (turf-monster CI run 42447610386).
|
|
11
|
+
//
|
|
12
|
+
// THE FIX, AT THE CAUSE. Turbo starts a transition only when both the current page and the new
|
|
13
|
+
// snapshot carry the view-transition meta (PageView#shouldTransitionTo). So when Turbo's own
|
|
14
|
+
// renderer says it will not render a response, this takes that meta out of the HTML Turbo
|
|
15
|
+
// parses. Turbo then swaps nothing in a transition and goes straight to its full load, which
|
|
16
|
+
// plays the page transition once (docs/PAGE_TRANSITIONS.md, "Full page loads"). Only a page
|
|
17
|
+
// Turbo was going to throw away is changed, and the page the full load brings is the server's
|
|
18
|
+
// own. No promise is marked handled, so a real failure (a duplicate view-transition-name, a
|
|
19
|
+
// render that throws) still surfaces.
|
|
20
|
+
//
|
|
21
|
+
// THE SEAM. turbo:before-fetch-response, the moment studio/loader also uses: Turbo awaits
|
|
22
|
+
// fetchResponse.responseHTML right after it, so a promise put there is what Turbo parses. The
|
|
23
|
+
// two compose in either order, since each reads the other's promise. A frame's response is left
|
|
24
|
+
// alone: a frame never transitions, and its reload is a new fetch.
|
|
25
|
+
//
|
|
26
|
+
// It imports nothing; test/javascript/reload_visits.test.mjs loads it as a data: module.
|
|
27
|
+
|
|
28
|
+
// The meta Turbo reads for a same-document transition: `view-transition` (same-origin), or
|
|
29
|
+
// the `turbo-view-transition` setting. Not `view-transition-*` names of anything else.
|
|
30
|
+
const TRANSITION_META = /<meta\b[^>]*\bname\s*=\s*["']?(?:turbo-)?view-transition(?![\w-])[^>]*>/gi
|
|
31
|
+
const VISIT_CONTROL_RELOAD = /<meta[^>]*(turbo-visit-control[^>]*reload|reload[^>]*turbo-visit-control)/i
|
|
32
|
+
|
|
33
|
+
// The head alone: Turbo's decision reads only the head, and the body is most of the bytes.
|
|
34
|
+
export function headOf(html) {
|
|
35
|
+
const end = html.search(/<\/head\s*>/i)
|
|
36
|
+
return end < 0 ? html : html.slice(0, end)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function prefersTransition(html) {
|
|
40
|
+
TRANSITION_META.lastIndex = 0
|
|
41
|
+
return TRANSITION_META.test(headOf(html))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Whether Turbo would render this HTML as the next page. Asked of Turbo's own PageRenderer, so
|
|
45
|
+
// the answer cannot drift from Turbo's (visit control, then the tracked assets). Without
|
|
46
|
+
// Turbo's classes, the visit-control meta alone, the case a host sets on purpose.
|
|
47
|
+
export function rendersAsPage(html, doc, turbo) {
|
|
48
|
+
const head = headOf(html)
|
|
49
|
+
if (turbo && turbo.PageRenderer && turbo.PageSnapshot) {
|
|
50
|
+
const current = turbo.PageSnapshot.fromElement(doc.documentElement)
|
|
51
|
+
const next = turbo.PageSnapshot.fromHTMLString(head)
|
|
52
|
+
return new turbo.PageRenderer(current, next, false).shouldRender
|
|
53
|
+
}
|
|
54
|
+
return !VISIT_CONTROL_RELOAD.test(head)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The HTML Turbo should parse: unchanged, unless it carries the transition meta and Turbo will
|
|
58
|
+
// not render it, and then the same HTML with that meta out of its head.
|
|
59
|
+
export function withoutReloadTransition(html, doc, turbo) {
|
|
60
|
+
if (typeof html !== "string" || !prefersTransition(html)) return html
|
|
61
|
+
// A Turbo whose classes changed shape leaves the HTML as it came: the old behaviour, not a
|
|
62
|
+
// failed visit.
|
|
63
|
+
try {
|
|
64
|
+
if (rendersAsPage(html, doc, turbo)) return html
|
|
65
|
+
} catch {
|
|
66
|
+
return html
|
|
67
|
+
}
|
|
68
|
+
const head = headOf(html)
|
|
69
|
+
return head.replace(TRANSITION_META, "") + html.slice(head.length)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function guardResponse(event, doc, turbo) {
|
|
73
|
+
if (event.target && event.target.tagName === "TURBO-FRAME") return false
|
|
74
|
+
const response = event.detail && event.detail.fetchResponse
|
|
75
|
+
if (!response || !response.isHTML) return false
|
|
76
|
+
const html = Promise.resolve(response.responseHTML).then((text) => withoutReloadTransition(text, doc, turbo))
|
|
77
|
+
Object.defineProperty(response, "responseHTML", { configurable: true, value: html })
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let installed = false
|
|
82
|
+
|
|
83
|
+
export function installReloadVisits(doc) {
|
|
84
|
+
if (installed || !doc || !doc.addEventListener) return false
|
|
85
|
+
installed = true
|
|
86
|
+
doc.addEventListener("turbo:before-fetch-response", (event) => {
|
|
87
|
+
const win = doc.defaultView
|
|
88
|
+
guardResponse(event, doc, win && win.Turbo)
|
|
89
|
+
})
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (typeof document !== "undefined") installReloadVisits(document)
|
|
@@ -1,21 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Studio
|
|
4
6
|
# One signed-in browser session: who, whether they were an admin, when it
|
|
5
7
|
# started and when it was last seen. It is a cached record for the app's own
|
|
6
8
|
# dashboard, kept on purpose without an IP, a user agent or a path.
|
|
7
9
|
#
|
|
8
10
|
# A new row starts after IDLE without a request. An open row is written at most
|
|
9
|
-
# once per THROTTLE, and the cookie remembers when
|
|
10
|
-
#
|
|
11
|
+
# once per THROTTLE, and the cookie remembers when.
|
|
12
|
+
#
|
|
13
|
+
# A DEVICE SESSION YOU CAN END (docs/DEVICE_SESSIONS.md). The cookie store keeps
|
|
14
|
+
# the whole session in the browser, so a logout used to delete only the
|
|
15
|
+
# browser's copy: a copy of the pre-logout cookie still signed in. Each signed-in
|
|
16
|
+
# cookie now carries a random `sid` in its mark, and every row of that device
|
|
17
|
+
# session carries the same sid. A logout stamps `revoked_at` on those rows, and
|
|
18
|
+
# every signed-in request reads its sid's rows (one indexed query): a revoked
|
|
19
|
+
# row ends the sid for good, and so does no row at all: the request is signed
|
|
20
|
+
# out. Other devices have other sids, so they stay signed in.
|
|
11
21
|
#
|
|
12
|
-
# Nil-safe, because the table
|
|
13
|
-
#
|
|
22
|
+
# Nil-safe, because the table and its columns come from migrations the host
|
|
23
|
+
# runs: an app without them records nothing, checks nothing and serves as
|
|
24
|
+
# before.
|
|
14
25
|
class UserSession < ApplicationRecord
|
|
15
26
|
self.table_name = "studio_user_sessions"
|
|
16
27
|
|
|
17
28
|
IDLE = 30.minutes
|
|
18
29
|
THROTTLE = 5.minutes
|
|
30
|
+
# A device session idle this long has its rows pruned, so its cookie no longer
|
|
31
|
+
# signs in: the user signs in again.
|
|
32
|
+
RETENTION = 90.days
|
|
33
|
+
PRUNE_EVERY = 1.hour
|
|
34
|
+
PRUNE_BATCH = 1_000
|
|
35
|
+
# A failed verdict writes at most one ErrorLog row per this window per
|
|
36
|
+
# process: a database fault fails every request, and the log must not add a
|
|
37
|
+
# write to each of them.
|
|
38
|
+
CHECK_FAILURE_LOG_EVERY = 5.minutes
|
|
19
39
|
|
|
20
40
|
belongs_to :user, class_name: "::User", optional: true
|
|
21
41
|
|
|
@@ -27,50 +47,225 @@ module Studio
|
|
|
27
47
|
def self.cookie_key = :"#{Studio.session_key}_session_mark"
|
|
28
48
|
|
|
29
49
|
# Off under test unless a test asks: the row a first request writes would count in
|
|
30
|
-
|
|
31
50
|
# every host's query-budget test, which measures the page and not this.
|
|
32
|
-
|
|
33
51
|
cattr_accessor :record_in_test, default: false
|
|
34
52
|
|
|
53
|
+
# A signed-in cookie with no sid predates this change. Until this moment it is
|
|
54
|
+
# accepted and given a sid on its request, so the deploy signs nobody out;
|
|
55
|
+
# after it, such a cookie is signed out once. The bound matters: a copy of a
|
|
56
|
+
# pre-deploy cookie carries no sid, so without it that copy would stay
|
|
57
|
+
# replayable after the device logs out.
|
|
58
|
+
cattr_accessor :legacy_grace_until, default: Time.utc(2026, 12, 8)
|
|
35
59
|
|
|
36
|
-
|
|
60
|
+
# When this process last pruned (prune_due!). Per process, so it costs nothing.
|
|
61
|
+
cattr_accessor :pruned_at, default: nil
|
|
37
62
|
|
|
38
|
-
|
|
63
|
+
# When this process last wrote an ErrorLog row for a failed verdict
|
|
64
|
+
# (log_check_failure). Per process, like pruned_at.
|
|
65
|
+
cattr_accessor :check_failure_logged_at, default: nil
|
|
39
66
|
|
|
67
|
+
def self.recording?
|
|
68
|
+
!Rails.env.test? || record_in_test
|
|
40
69
|
end
|
|
41
70
|
|
|
42
|
-
|
|
43
71
|
def self.available?
|
|
44
72
|
connection.data_source_exists?(table_name)
|
|
45
73
|
rescue ActiveRecord::ActiveRecordError, NoMethodError
|
|
46
74
|
false
|
|
47
75
|
end
|
|
48
76
|
|
|
77
|
+
# The table carries the sid and revoked_at columns. The schema cache and the
|
|
78
|
+
# model's column cache answer it, so a request pays no query for it.
|
|
79
|
+
def self.revocable?
|
|
80
|
+
return false unless connection.schema_cache.data_source_exists?(table_name)
|
|
81
|
+
|
|
82
|
+
names = column_names
|
|
83
|
+
names.include?("sid") && names.include?("revoked_at")
|
|
84
|
+
rescue ActiveRecord::ActiveRecordError, NoMethodError
|
|
85
|
+
false
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# This app's mark in `store` (the session, or a plain Hash decoded from the
|
|
89
|
+
# cookie, as a cable connection reads it), as a string-keyed Hash.
|
|
90
|
+
def self.mark_in(store)
|
|
91
|
+
mark = store[cookie_key]
|
|
92
|
+
mark = store[cookie_key.to_s] if mark.nil? && store.is_a?(Hash)
|
|
93
|
+
mark.respond_to?(:to_h) ? mark.to_h.transform_keys(&:to_s) : {}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def self.new_sid = SecureRandom.urlsafe_base64(24)
|
|
97
|
+
|
|
98
|
+
# The verdict on a signed-in request, read before anything else runs. Answers
|
|
99
|
+
# [verdict, row]:
|
|
100
|
+
#
|
|
101
|
+
# :live the sid names a row that is not revoked (row is that row)
|
|
102
|
+
# :ended any row of the sid is revoked, or the sid has no row (pruned,
|
|
103
|
+
# or never written); also a sid-less cookie past legacy_grace_until
|
|
104
|
+
# :legacy no sid, inside the grace: accepted, and track gives it one
|
|
105
|
+
# :off nothing to check (not recording, or no columns)
|
|
106
|
+
#
|
|
107
|
+
# One indexed query when the cookie carries a sid, none otherwise. A failure
|
|
108
|
+
# answers :off, so a database fault signs nobody out: the request's own user
|
|
109
|
+
# lookup needs the same database, and a copied cookie cannot cause the fault.
|
|
110
|
+
def self.verdict(store, now: Time.current)
|
|
111
|
+
return [:off, nil] unless recording? && revocable?
|
|
112
|
+
|
|
113
|
+
sid = mark_in(store)["sid"].presence
|
|
114
|
+
return [now < legacy_grace_until ? :legacy : :ended, nil] unless sid
|
|
115
|
+
|
|
116
|
+
# A revoked row sorts first: a row a request in flight wrote after the
|
|
117
|
+
# logout cannot bring the sid back.
|
|
118
|
+
row = where(sid: sid.to_s).order(Arel.sql("revoked_at IS NULL"), id: :desc).first
|
|
119
|
+
return [:ended, nil] if row.nil? || row.revoked_at
|
|
120
|
+
|
|
121
|
+
[:live, row]
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
Rails.logger&.warn("[user_session] verdict #{e.class}: #{e.message}")
|
|
124
|
+
log_check_failure(e, now)
|
|
125
|
+
[:off, nil]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# True when `store` names an ended device session (verdict :ended). The one
|
|
129
|
+
# check a host's ApplicationCable::Connection makes, since /cable reads the
|
|
130
|
+
# cookie outside the controller filter (docs/DEVICE_SESSIONS.md):
|
|
131
|
+
#
|
|
132
|
+
# return nil if Studio::UserSession.respond_to?(:ended?) && Studio::UserSession.ended?(session)
|
|
133
|
+
#
|
|
134
|
+
# `store` is request.session or the Hash decoded from the cookie. :legacy and
|
|
135
|
+
# :off answer false, as they do for a page request. It never writes `store`.
|
|
136
|
+
def self.ended?(store, now: Time.current)
|
|
137
|
+
verdict(store, now: now).first == :ended
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Writes the verdict's failure to ErrorLog, at most once per
|
|
141
|
+
# CHECK_FAILURE_LOG_EVERY per process. The stamp is set before the write, so a
|
|
142
|
+
# write that fails too (the database is down) is not retried on every request.
|
|
143
|
+
# Never raises.
|
|
144
|
+
def self.log_check_failure(exception, now = Time.current)
|
|
145
|
+
return if check_failure_logged_at && check_failure_logged_at > now - CHECK_FAILURE_LOG_EVERY
|
|
146
|
+
|
|
147
|
+
self.check_failure_logged_at = now
|
|
148
|
+
::ErrorLog.capture!(exception) if defined?(::ErrorLog) && ::ErrorLog.respond_to?(:capture!)
|
|
149
|
+
rescue StandardError => e
|
|
150
|
+
Rails.logger&.warn("[user_session] check failure not logged: #{e.class}: #{e.message}")
|
|
151
|
+
nil
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Starts this device's session at a sign-in (ErrorHandling#set_app_session):
|
|
155
|
+
# writes its first row with a new sid and marks `store`, so the very next
|
|
156
|
+
# request is checked. A sign-in by the same user over a live sid keeps it: a
|
|
157
|
+
# re-auth is the same device session. The sid is read again first (one indexed
|
|
158
|
+
# query): a revoked sid, or one with no row, is never reused, so a sign-in on
|
|
159
|
+
# a copied cookie cannot bring an ended session back. A different user gets a
|
|
160
|
+
# new sid; the old one is not revoked, since a sign-in is not a logout. Answers
|
|
161
|
+
# the row, or nil. It raises on a failed write, so the caller can log it.
|
|
162
|
+
def self.start!(store, user, now: Time.current)
|
|
163
|
+
return unless user && recording? && revocable?
|
|
164
|
+
|
|
165
|
+
mark = mark_in(store)
|
|
166
|
+
return if mark["user"] == user.id && live_sid?(mark["sid"], user)
|
|
167
|
+
|
|
168
|
+
admin = user.respond_to?(:admin?) && user.admin?
|
|
169
|
+
row = create!(sid: new_sid, user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now)
|
|
170
|
+
store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i, "sid" => row.sid }
|
|
171
|
+
prune_due!(now)
|
|
172
|
+
row
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Ends this device's session: stamps revoked_at on every row of its sid and
|
|
176
|
+
# drops the mark from `store`. Other sids (other devices, and other apps on a
|
|
177
|
+
# shared cookie) are untouched. Answers the rows it revoked. It raises on a
|
|
178
|
+
# failed write, so the caller can log it (ErrorHandling#end_device_session).
|
|
179
|
+
def self.revoke!(store, now: Time.current)
|
|
180
|
+
sid = mark_in(store)["sid"].presence
|
|
181
|
+
store.delete(cookie_key)
|
|
182
|
+
return 0 unless sid && recording? && revocable?
|
|
183
|
+
|
|
184
|
+
where(sid: sid.to_s, revoked_at: nil).update_all(revoked_at: now, updated_at: now)
|
|
185
|
+
end
|
|
186
|
+
|
|
49
187
|
# Records this request for `user`, reading and writing `store` (the session).
|
|
50
|
-
#
|
|
51
|
-
#
|
|
52
|
-
|
|
188
|
+
# `row` is the row verdict read for this request, so tracking adds no read of
|
|
189
|
+
# its own. Answers the row it wrote, or nil. Never raises: a request must not
|
|
190
|
+
# fail because its bookkeeping did.
|
|
191
|
+
def self.track(store, user, now: Time.current, row: nil)
|
|
53
192
|
return if user.nil? || !recording?
|
|
54
193
|
|
|
55
|
-
mark = store
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
194
|
+
mark = mark_in(store)
|
|
195
|
+
same_user = mark["user"] == user.id
|
|
196
|
+
seen = same_user && mark["at"] ? Time.zone.at(mark["at"].to_i) : nil
|
|
197
|
+
revocable = revocable?
|
|
198
|
+
sid = same_user ? mark["sid"].presence : nil
|
|
199
|
+
# A sid-less cookie does not wait out the throttle: it gets its sid now.
|
|
200
|
+
return if seen && seen > now - THROTTLE && (sid || !revocable)
|
|
60
201
|
return unless available?
|
|
61
202
|
|
|
62
|
-
row =
|
|
203
|
+
row = revocable && sid ? row_for_sid(sid, user, now, row) : legacy_row(mark, same_user, seen, user, now, revocable)
|
|
63
204
|
if row
|
|
64
|
-
|
|
205
|
+
changes = { last_seen_at: now }
|
|
206
|
+
changes[:sid] = sid = new_sid if revocable && sid.nil?
|
|
207
|
+
row.update_columns(changes)
|
|
65
208
|
else
|
|
209
|
+
sid ||= new_sid if revocable
|
|
66
210
|
admin = user.respond_to?(:admin?) && user.admin?
|
|
67
|
-
|
|
211
|
+
attrs = { user_id: user.id, admin: admin ? true : false, started_at: now, last_seen_at: now }
|
|
212
|
+
attrs[:sid] = sid if revocable
|
|
213
|
+
row = create!(attrs)
|
|
214
|
+
prune_due!(now)
|
|
68
215
|
end
|
|
69
|
-
store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }
|
|
216
|
+
store[cookie_key] = { "id" => row.id, "user" => user.id, "at" => now.to_i }.tap { |m| m["sid"] = sid if revocable }
|
|
70
217
|
row
|
|
71
218
|
rescue StandardError => e
|
|
72
219
|
Rails.logger&.warn("[user_session] #{e.class}: #{e.message}")
|
|
73
220
|
nil
|
|
74
221
|
end
|
|
222
|
+
|
|
223
|
+
# True when `sid` has rows, all of them unrevoked, and the latest is `user`'s.
|
|
224
|
+
# One query on the sid index; a revoked row sorts first, as in verdict.
|
|
225
|
+
def self.live_sid?(sid, user)
|
|
226
|
+
return false if sid.blank?
|
|
227
|
+
|
|
228
|
+
row = where(sid: sid.to_s).order(Arel.sql("revoked_at IS NULL"), id: :desc).first
|
|
229
|
+
!row.nil? && row.revoked_at.nil? && row.user_id == user.id
|
|
230
|
+
end
|
|
231
|
+
private_class_method :live_sid?
|
|
232
|
+
|
|
233
|
+
# The open row to move for this sid: the one verdict read, else the latest by
|
|
234
|
+
# sid. Past IDLE nil, so a new row starts with the same sid.
|
|
235
|
+
def self.row_for_sid(sid, user, now, row)
|
|
236
|
+
row = nil unless row && row.sid == sid
|
|
237
|
+
row ||= where(sid: sid).order(id: :desc).first
|
|
238
|
+
row if row && row.user_id == user.id && row.revoked_at.nil? && row.last_seen_at > now - IDLE
|
|
239
|
+
end
|
|
240
|
+
private_class_method :row_for_sid
|
|
241
|
+
|
|
242
|
+
# The row a sid-less mark names (a cookie from before sids, or before this
|
|
243
|
+
# app's columns), inside IDLE. Under sids only a row with no sid of its own
|
|
244
|
+
# qualifies: it takes the cookie's new sid.
|
|
245
|
+
def self.legacy_row(mark, same_user, seen, user, now, revocable)
|
|
246
|
+
return unless same_user && seen && seen > now - IDLE
|
|
247
|
+
|
|
248
|
+
row = find_by(id: mark["id"], user_id: user.id)
|
|
249
|
+
row unless revocable && row&.sid.present?
|
|
250
|
+
end
|
|
251
|
+
private_class_method :legacy_row
|
|
252
|
+
|
|
253
|
+
# Deletes up to `batch` rows last seen before `before`. One statement on the
|
|
254
|
+
# last_seen_at index.
|
|
255
|
+
def self.prune!(now: Time.current, before: now - RETENTION, batch: PRUNE_BATCH)
|
|
256
|
+
where(id: where(arel_table[:last_seen_at].lt(before)).order(:id).limit(batch).select(:id)).delete_all
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Prunes at most once per PRUNE_EVERY per process, from the moment a row is
|
|
260
|
+
# created (a sign-in or a return after IDLE), so no host has to schedule a job.
|
|
261
|
+
def self.prune_due!(now = Time.current)
|
|
262
|
+
return if pruned_at && pruned_at > now - PRUNE_EVERY
|
|
263
|
+
|
|
264
|
+
self.pruned_at = now
|
|
265
|
+
prune!(now: now)
|
|
266
|
+
rescue StandardError => e
|
|
267
|
+
Rails.logger&.warn("[user_session] prune #{e.class}: #{e.message}")
|
|
268
|
+
nil
|
|
269
|
+
end
|
|
75
270
|
end
|
|
76
271
|
end
|