studio-engine 0.91.0 → 0.91.2
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 +44 -0
- data/README.md +5 -0
- data/app/controllers/concerns/studio/link_consumption.rb +2 -2
- data/app/controllers/sessions_controller.rb +8 -2
- data/app/models/studio/site_identity.rb +2 -2
- data/app/views/studio/surveys/_question.html.erb +1 -0
- data/app/views/studio/surveys/_script.html.erb +5 -2
- data/app/views/studio/surveys/_styles.html.erb +7 -1
- data/lib/studio/booking.rb +5 -3
- data/lib/studio/link_token.rb +5 -3
- data/lib/studio/local_path.rb +28 -0
- data/lib/studio/version.rb +1 -1
- data/lib/studio.rb +14 -3
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 270fe9f569b46f626593afa13ce7cf925f23e783541f76e06d6a465d06837278
|
|
4
|
+
data.tar.gz: 40f8eefcec2e4eee79baa652da57aca97a6b49b794fab4fb3ca166dd3f27333d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ae76749cc7fd004b7a93b2108d0af84e21645b1801f50be212d0d5346d503f8d3355fea7c3350ab74a059edbcfc4d60163b245ae34c7f4c68a249e1115b5588f
|
|
7
|
+
data.tar.gz: 683b4be2cb7b68b01c167db0e3973aefe9b0dd133b8ddb200f5d76f387d19dc1bff6378204fc0e34c4716e1b08bd9e0d08b4f572e40d3b2c15d007e97a6ba48e
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,50 @@ The format is [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This pro
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.91.2 — 2026-10-06
|
|
8
|
+
|
|
9
|
+
### Security
|
|
10
|
+
|
|
11
|
+
- **A return path beginning `/\` no longer redirects off-site.** Browsers read
|
|
12
|
+
`/\evil.com` as `//evil.com`, and the engine's path sanitizers checked only for
|
|
13
|
+
one leading `/` and not two, so a magic link minted with `return_to=/\evil.com`
|
|
14
|
+
sent the visitor to another host after sign-in (or, under Rails'
|
|
15
|
+
`raise_on_open_redirects`, answered 500). One predicate, `Studio::LocalPath.local?`,
|
|
16
|
+
now holds the rule (one leading `/`, no backslash, no control character) and
|
|
17
|
+
every engine site uses it: `Studio::LinkToken.sanitize_path`, the link-consume
|
|
18
|
+
redirect, `Studio::Booking.local_path?`, and the link-preview fallback image.
|
|
19
|
+
Each site keeps its fallback: a refused return path lands on the default page.
|
|
20
|
+
Consumers that sanitize a return path themselves should call
|
|
21
|
+
`Studio::LocalPath.local?` instead of a bare `start_with?("/")` check.
|
|
22
|
+
|
|
23
|
+
## 0.91.1 — 2026-10-06
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **`POST /login` is drawn only for apps that declare `:password`.** A passwordless
|
|
28
|
+
app answered it 500 for a member's address and 422 for a stranger's, so the
|
|
29
|
+
answer revealed who is a member; it now answers 404 for every address. `GET
|
|
30
|
+
/login` and `login_path` are unchanged. An app that still wants a `POST /login`
|
|
31
|
+
of its own draws it before `Studio.routes(self)`.
|
|
32
|
+
|
|
33
|
+
- **SSO sign-up works on passwordless apps.** "Continue as …" (`POST
|
|
34
|
+
/sso_continue`) created a first-time visitor with `password:
|
|
35
|
+
SecureRandom.hex(16)`, which raised `UnknownAttributeError` on any User without
|
|
36
|
+
`has_secure_password`; the engine logged it and bounced to the login page. The
|
|
37
|
+
engine now sets a random password only when the User answers `password=`.
|
|
38
|
+
- **The survey keeps its h1 for a screen reader on every step.** From question 2
|
|
39
|
+
on, the stepper set `hidden` on the survey head, which took the page's only
|
|
40
|
+
h1 out of the accessibility tree. The head now steps into a 1px visual clip
|
|
41
|
+
instead, holding only the title.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
|
|
45
|
+
- **The survey partials declare strict locals.** `studio/surveys/_question`
|
|
46
|
+
takes `question:`, `index:`, `total:`, `response:` and an optional `error:`;
|
|
47
|
+
`_script` and `_styles` take none. A render with an unknown or missing local
|
|
48
|
+
now raises instead of painting a blank. The survey accent's fallback reads
|
|
49
|
+
`Studio::ThemeResolver::DEFAULT_PRIMARY` instead of a copied hex.
|
|
50
|
+
|
|
7
51
|
## 0.91.0 — 2026-10-06
|
|
8
52
|
|
|
9
53
|
### Added
|
data/README.md
CHANGED
|
@@ -82,6 +82,11 @@ sign-in-link form when `:magic_link` is enabled, and the Google button only with
|
|
|
82
82
|
and using the link creates the account. So `registration_params`' `:name` shows a
|
|
83
83
|
Name field only on a password app.
|
|
84
84
|
|
|
85
|
+
`POST /login`, the password exchange, is drawn only when `auth_methods` includes
|
|
86
|
+
`:password`. A passwordless app answers it with 404 for every address, so it
|
|
87
|
+
cannot tell a member from a stranger. `GET /login` and `login_path` are drawn for
|
|
88
|
+
every app.
|
|
89
|
+
|
|
85
90
|
### Local log rotation (automatic — nothing to configure)
|
|
86
91
|
|
|
87
92
|
The engine caps the host app's **development** log at 16 MB and its **test** log
|
|
@@ -163,10 +163,10 @@ module Studio
|
|
|
163
163
|
user.update!(email_verified_at: Time.current)
|
|
164
164
|
end
|
|
165
165
|
|
|
166
|
-
# Only
|
|
166
|
+
# Only local paths survive (Studio::LocalPath); everything else collapses to nil.
|
|
167
167
|
def safe_path(path)
|
|
168
168
|
p = path.to_s
|
|
169
|
-
|
|
169
|
+
Studio::LocalPath.local?(p) ? p : nil
|
|
170
170
|
end
|
|
171
171
|
end
|
|
172
172
|
end
|
|
@@ -55,9 +55,15 @@ class SessionsController < ApplicationController
|
|
|
55
55
|
email: session[:sso_email],
|
|
56
56
|
name: session[:sso_name],
|
|
57
57
|
provider: session[:sso_provider],
|
|
58
|
-
uid: session[:sso_uid]
|
|
59
|
-
password: SecureRandom.hex(16)
|
|
58
|
+
uid: session[:sso_uid]
|
|
60
59
|
)
|
|
60
|
+
# Only a host that takes a password gets one: has_secure_password defines
|
|
61
|
+
# `password=` and requires a value on create, so those apps need a random
|
|
62
|
+
# placeholder. A passwordless User has no such writer, and passing
|
|
63
|
+
# `password:` to User.new raised UnknownAttributeError, which sso_continue
|
|
64
|
+
# rescued into a redirect: SSO sign-up failed silently on every passwordless
|
|
65
|
+
# app (/tasks/engine-sso-creates-passwordless-users).
|
|
66
|
+
user.password = SecureRandom.hex(16) if user.respond_to?(:password=)
|
|
61
67
|
Studio.configure_sso_user.call(user)
|
|
62
68
|
rescue_and_log(target: user) do
|
|
63
69
|
user.save!
|
|
@@ -183,7 +183,7 @@ module Studio
|
|
|
183
183
|
def static_image
|
|
184
184
|
path = Studio.link_preview_fallback_image.to_s
|
|
185
185
|
return nil if path.empty?
|
|
186
|
-
return path unless
|
|
186
|
+
return path unless Studio::LocalPath.local?(path)
|
|
187
187
|
|
|
188
188
|
@static_files ||= Concurrent::Map.new
|
|
189
189
|
present = @static_files.compute_if_absent(path) do
|
|
@@ -203,7 +203,7 @@ module Studio
|
|
|
203
203
|
# per path like static_image.
|
|
204
204
|
def static_image_dimensions
|
|
205
205
|
path = static_image
|
|
206
|
-
return nil
|
|
206
|
+
return nil unless Studio::LocalPath.local?(path)
|
|
207
207
|
|
|
208
208
|
@static_dimensions ||= Concurrent::Map.new
|
|
209
209
|
dims = @static_dimensions.compute_if_absent(path) do
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%# locals: (question:, index:, total:, response:, error: nil) %>
|
|
1
2
|
<%# One survey question as a fieldset (studio/surveys/show). It is a complete
|
|
2
3
|
form control without JavaScript: native radios, checkboxes and text fields
|
|
3
4
|
named answers[key], with `required` so the no-JS form validates in the
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%# locals: () %>
|
|
1
2
|
<%# The survey stepper: progressive enhancement over studio/surveys/show.
|
|
2
3
|
Plain JavaScript (no Alpine dependency), idempotent per root so a Turbo
|
|
3
4
|
render that re-runs it never double-binds. Bound roots are tracked in a
|
|
@@ -137,8 +138,10 @@
|
|
|
137
138
|
back.hidden = first;
|
|
138
139
|
next.hidden = last;
|
|
139
140
|
submit.hidden = !last;
|
|
140
|
-
// The title and intro lead question 1 only; later screens
|
|
141
|
-
|
|
141
|
+
// The title and intro lead question 1 only; later screens show the
|
|
142
|
+
// question alone. The head steps into a visual clip rather than
|
|
143
|
+
// `hidden`, so the page keeps its h1 for a screen reader on every step.
|
|
144
|
+
if (head) head.classList.toggle("studio-survey__head--later", !first);
|
|
142
145
|
// The number-key shortcut only means something on a choice or scale.
|
|
143
146
|
var step = steps[index];
|
|
144
147
|
hint.hidden = !(step.dataset.key && (isChoice(step) || isMulti(step)));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
<%# locals: () %>
|
|
1
2
|
<%# Survey styles, scoped under .studio-survey. Plain CSS on the theme's CSS
|
|
2
3
|
custom properties (--color-cta, --color-surface, ...) rather than Tailwind
|
|
3
4
|
utilities, so the page looks the same in every host whatever its Tailwind
|
|
@@ -6,7 +7,7 @@
|
|
|
6
7
|
<style>
|
|
7
8
|
/* overflow-x: clip, not hidden: the slide-in must never widen the page, and
|
|
8
9
|
clip makes no scroll container, so the sticky nav still sticks. */
|
|
9
|
-
.studio-survey { --sv-accent: var(--color-cta,
|
|
10
|
+
.studio-survey { --sv-accent: var(--color-cta, <%= Studio::ThemeResolver::DEFAULT_PRIMARY %>); display: flex; justify-content: center; padding: 1.25rem 1rem 2rem; color: var(--color-text-body); overflow-x: clip; }
|
|
10
11
|
.studio-survey [hidden] { display: none !important; }
|
|
11
12
|
.studio-survey__shell { width: 100%; max-width: 36rem; }
|
|
12
13
|
.studio-survey__sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
@@ -25,6 +26,11 @@
|
|
|
25
26
|
.studio-survey.is-enhanced .studio-survey__head { margin-bottom: 1.25rem; }
|
|
26
27
|
.studio-survey.is-enhanced .studio-survey__title { margin: 0 0 .375rem; font-size: 1rem; line-height: 1.4; font-weight: 700; color: var(--color-text-secondary); }
|
|
27
28
|
.studio-survey.is-enhanced .studio-survey__head--echo .studio-survey__title { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
29
|
+
/* After question 1 the head steps aside visually, but its h1 stays the page's
|
|
30
|
+
heading for a screen reader: the head goes into a 1px clip and only the
|
|
31
|
+
title remains inside it. */
|
|
32
|
+
.studio-survey.is-enhanced .studio-survey__head--later { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
|
|
33
|
+
.studio-survey.is-enhanced .studio-survey__head--later > :not(.studio-survey__title) { display: none; }
|
|
28
34
|
.studio-survey.is-enhanced .studio-survey__intro-copy { margin: 0; font-size: .9375rem; line-height: 1.5; color: var(--color-text-secondary); }
|
|
29
35
|
.studio-survey.is-enhanced .studio-survey__meta { display: none; }
|
|
30
36
|
.studio-survey__question { display: block; width: 100%; padding: 0; margin: 0 0 .5rem; font-size: 1.375rem; line-height: 1.3; font-weight: 700; color: var(--color-text); outline: none; }
|
data/lib/studio/booking.rb
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
#
|
|
12
12
|
# `gv=true` is what makes Google serve the embeddable page, so the frame and the
|
|
13
13
|
# popup add it; the "open the booking page" link does not.
|
|
14
|
+
require_relative "local_path"
|
|
15
|
+
|
|
14
16
|
module Studio
|
|
15
17
|
module Booking
|
|
16
18
|
EMBED_PARAM = "gv=true"
|
|
@@ -116,10 +118,10 @@ module Studio
|
|
|
116
118
|
# hrefs (the footer's booking links, `studio_booking_link`). It begins with
|
|
117
119
|
# one "/" and carries no control character. That refuses a scheme
|
|
118
120
|
# ("javascript:", "https:") and a protocol-relative "//host", which a browser
|
|
119
|
-
# reads as another site.
|
|
121
|
+
# reads as another site. The rule is Studio::LocalPath's, shared with every
|
|
122
|
+
# other engine path sanitizer.
|
|
120
123
|
def local_path?(path)
|
|
121
|
-
|
|
122
|
-
string.start_with?("/") && !string.start_with?("//") && !string.match?(/[\x00-\x1f\x7f\\]/)
|
|
124
|
+
Studio::LocalPath.local?(path)
|
|
123
125
|
end
|
|
124
126
|
|
|
125
127
|
# Studio.booking_path: nil, a local path, or a callable receiving the view.
|
data/lib/studio/link_token.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "securerandom"
|
|
4
|
+
require_relative "local_path"
|
|
4
5
|
|
|
5
6
|
module Studio
|
|
6
7
|
# Pure-Ruby helpers behind the Studio::Link model — token minting, the kind
|
|
@@ -53,12 +54,13 @@ module Studio
|
|
|
53
54
|
email.to_s.strip.downcase
|
|
54
55
|
end
|
|
55
56
|
|
|
56
|
-
# Only
|
|
57
|
+
# Only local paths survive (Studio::LocalPath: one leading "/", no
|
|
58
|
+
# backslash, no control character); protocol-relative ("//evil", "/\evil"),
|
|
57
59
|
# absolute URLs, and blanks collapse to nil so callers fall back to a safe
|
|
58
|
-
# default redirect.
|
|
60
|
+
# default redirect.
|
|
59
61
|
def sanitize_path(path)
|
|
60
62
|
p = path.to_s
|
|
61
|
-
|
|
63
|
+
Studio::LocalPath.local?(p) ? p : nil
|
|
62
64
|
end
|
|
63
65
|
end
|
|
64
66
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Studio
|
|
4
|
+
# The one rule for "is this a path on THIS site?", used wherever the engine
|
|
5
|
+
# writes a caller-supplied path into a redirect or an href: a sign-in's
|
|
6
|
+
# return_to, a link's target, the booking page, the link-preview fallback image.
|
|
7
|
+
#
|
|
8
|
+
# A local path begins with exactly one "/" and carries no control character
|
|
9
|
+
# and no backslash. Each clause closes a way a browser leaves the site:
|
|
10
|
+
# "https://x", "javascript:x" - no leading "/": a scheme or a relative path
|
|
11
|
+
# "//x" - protocol-relative: another host
|
|
12
|
+
# "/\x", "/\/x" - browsers (the WHATWG URL parser) read "\" as
|
|
13
|
+
# "/", so these are "//x" in disguise
|
|
14
|
+
# "/\tx", "/\nx" - the URL parser strips tab and newline, so a
|
|
15
|
+
# control character can hide a second "/"
|
|
16
|
+
# Pure Ruby, so it loads and unit-tests without Rails.
|
|
17
|
+
module LocalPath
|
|
18
|
+
# A control character (C0 or DEL) or a backslash anywhere in the path.
|
|
19
|
+
UNSAFE_CHARACTER = /[\x00-\x1f\x7f\\]/
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def local?(path)
|
|
24
|
+
string = path.to_s
|
|
25
|
+
string.start_with?("/") && !string.start_with?("//") && !string.match?(UNSAFE_CHARACTER)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/studio/version.rb
CHANGED
data/lib/studio.rb
CHANGED
|
@@ -12,6 +12,7 @@ require "active_support/core_ext/module/attribute_accessors"
|
|
|
12
12
|
require "active_support/core_ext/numeric/time"
|
|
13
13
|
require "active_support/core_ext/integer/time"
|
|
14
14
|
require "studio/version"
|
|
15
|
+
require "studio/local_path"
|
|
15
16
|
require "studio/log_rotation"
|
|
16
17
|
require "studio/ip_locations"
|
|
17
18
|
require "studio/geo"
|
|
@@ -146,8 +147,9 @@ module Studio
|
|
|
146
147
|
# Which sign-in methods this app offers. The shared login/signup views render
|
|
147
148
|
# a button/field per enabled method (gate with Studio.auth_method?). Order is
|
|
148
149
|
# display order. Both McRitchie Studio + Turf Monster are passwordless; legacy
|
|
149
|
-
# email+password is opt-in via :password
|
|
150
|
-
# contract check
|
|
150
|
+
# email+password is opt-in via :password, which also draws POST /login (see
|
|
151
|
+
# Studio.routes) and re-arms the User#authenticate contract check (see
|
|
152
|
+
# validate_user_contract!).
|
|
151
153
|
#
|
|
152
154
|
# :wallet is deliberately NOT in the default. This engine plus McRitchie Studio
|
|
153
155
|
# is the BASE template for every app, web2 and web3 alike; solana-studio plus
|
|
@@ -1336,7 +1338,16 @@ module Studio
|
|
|
1336
1338
|
def self.routes(router)
|
|
1337
1339
|
router.instance_exec do
|
|
1338
1340
|
get "login", to: "sessions#new"
|
|
1339
|
-
|
|
1341
|
+
|
|
1342
|
+
# Email + password sign-in, drawn only for an app that declares :password.
|
|
1343
|
+
# SessionsController#create calls user.authenticate, which a passwordless
|
|
1344
|
+
# User does not define: on a passwordless app this POST answered 500 for a
|
|
1345
|
+
# member's address and 422 for a stranger's, which told anyone who asked
|
|
1346
|
+
# who the members are. Undrawn, it answers 404 for every address.
|
|
1347
|
+
# GET /login stays drawn for every app: it is the sign-in page, and
|
|
1348
|
+
# login_path names it.
|
|
1349
|
+
post "login", to: "sessions#create" if Studio.auth_method?(:password)
|
|
1350
|
+
|
|
1340
1351
|
post "sso_continue", to: "sessions#sso_continue"
|
|
1341
1352
|
get "sso_login", to: "sessions#sso_login"
|
|
1342
1353
|
get "logout", to: "sessions#destroy"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: studio-engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.91.
|
|
4
|
+
version: 0.91.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Alex McRitchie
|
|
@@ -614,6 +614,7 @@ files:
|
|
|
614
614
|
- lib/studio/link_preview.rb
|
|
615
615
|
- lib/studio/link_resolution.rb
|
|
616
616
|
- lib/studio/link_token.rb
|
|
617
|
+
- lib/studio/local_path.rb
|
|
617
618
|
- lib/studio/log_rotation.rb
|
|
618
619
|
- lib/studio/mail_transport.rb
|
|
619
620
|
- lib/studio/name_parts.rb
|