product_tours 0.3.1 → 1.0.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/AGENTS.md +11 -5
- data/CHANGELOG.md +78 -3
- data/README.md +65 -6
- data/Rakefile +9 -1
- data/SECURITY.md +50 -0
- data/app/controllers/concerns/product_tours/request_context.rb +3 -3
- data/app/controllers/product_tours/dashboard_controller.rb +1 -1
- data/app/controllers/product_tours/interactions_controller.rb +77 -0
- data/app/controllers/product_tours/media_controller.rb +23 -4
- data/app/controllers/product_tours/tours_controller.rb +203 -52
- data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
- data/app/models/product_tours/{post.rb → tour.rb} +15 -15
- data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
- data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
- data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
- data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
- data/app/views/product_tours/tours/new.html.erb +7 -0
- data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
- data/config/routes.rb +4 -4
- data/lib/generators/product_tours/install/install_generator.rb +3 -3
- data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
- data/lib/product_tours/dashboard.css +1 -1
- data/lib/product_tours/dashboard.js +16 -16
- data/lib/product_tours/seeds.rb +175 -58
- data/lib/product_tours/version.rb +1 -1
- data/lib/product_tours/widget.js +28 -25
- data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
- data/lib/product_tours.rb +1 -0
- data/lib/tasks/product_tours_tasks.rake +3 -3
- metadata +20 -13
- data/app/controllers/product_tours/posts_controller.rb +0 -228
- data/app/views/product_tours/posts/new.html.erb +0 -7
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8c3fecc3261d2acf6fb9267139897e7e0c4c6fa9dd3a8d52466380f2fe040c9e
|
|
4
|
+
data.tar.gz: 0112d06e16cd88171023f3dd32988348e8dd642334db67c5f33cb1c4194363a4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 670382b8c322e3803495d1a294aebfc21036aa69400fb7e9055a98de1243c8ba1e716dadacfcd0909885cbd18e77914f7114f54cb2caf1b388e8e9e386ec00da
|
|
7
|
+
data.tar.gz: 54eb9697c17e7fd50f170dd21e7d15ee994459acd4b7151d98def0f2622586ca40f913dba9f451ceb7676ac5c79cb33bb946b55469e2c342372c83ba4fecc189
|
data/AGENTS.md
CHANGED
|
@@ -5,7 +5,7 @@ Instructions for coding agents. Two audiences:
|
|
|
5
5
|
- **[Installing product_tours into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add product tours, onboarding guides, or video tutorials.
|
|
6
6
|
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
7
|
|
|
8
|
-
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for uploaded videos, Action Text only for rich descriptions — both optional and both degrade rather than raise.
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1 and < 9. Active Storage only for uploaded videos, Action Text only for rich descriptions — both optional and both degrade rather than raise.
|
|
9
9
|
|
|
10
10
|
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show product_tours)/AGENTS.md"`.
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ bin/rails generate product_tours:install
|
|
|
21
21
|
bin/rails db:migrate
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
The generator writes `config/initializers/product_tours.rb`, one migration (`
|
|
24
|
+
The generator writes `config/initializers/product_tours.rb`, one migration (`product_tours_tours`), and `mount_product_tours at: "/product_tours"` into `config/routes.rb`. **In development the migration also inserts working demo tutorials**, so the modal has something to open before anyone has written content. Read the initializer it wrote — it is the source of truth over any summary of it, including this file.
|
|
25
25
|
|
|
26
26
|
### 2. Wire the three things the generator cannot
|
|
27
27
|
|
|
@@ -69,7 +69,7 @@ Then in the running app: open `/product_tours`, confirm the demo tutorials are l
|
|
|
69
69
|
|
|
70
70
|
### Tutorials are content, not code
|
|
71
71
|
|
|
72
|
-
A tutorial is a row in `
|
|
72
|
+
A tutorial is a row in `product_tours_tours`, written and published in the mounted dashboard. **Do not create tutorials from host-app migrations, seeds, or fixtures** — that is not how this gem is meant to be used, and it puts editorial content in schema history. If the app needs sample content, `bin/rails product_tours:seed_demo` is the supported path.
|
|
73
73
|
|
|
74
74
|
What a tutorial carries: a `key`, a title, an optional rich description, an optional video, and at most one primary action (a URL, or the key of the next tutorial — that link is what makes a walkthrough). Keys must match `/\A[a-z0-9]+(?:[._-][a-z0-9]+)*\z/` — lowercase, digits, and `. _ -` as separators. `Billing Setup` and `billingSetup` are invalid; `billing_setup` and `billing.step-1` are fine. `status` is `draft` or `published`, and the key is unique **per locale**, which is how translations work: one row per language for the same key, with locale fallback at resolve time.
|
|
75
75
|
|
|
@@ -112,7 +112,7 @@ exceptions without breaking the visitor flow. Keep it fast or enqueue a job.
|
|
|
112
112
|
- **Do not create or edit tutorials in code** (see above).
|
|
113
113
|
- **Do not add provider hosts to `frame-src` by hand** — the engine already does it.
|
|
114
114
|
- **Do not serve uploaded videos by blob URL** — the gated media route exists so a leaked signed URL cannot hand over your content.
|
|
115
|
-
- **Do not install Active Storage or Action Text "to make it work"** unless the app actually wants uploads or rich text. Both are optional; the gem checks for them (`
|
|
115
|
+
- **Do not install Active Storage or Action Text "to make it work"** unless the app actually wants uploads or rich text. Both are optional; the gem checks for them (`Tour.video_upload_supported?`, `Tour.description_supported?`) and simply offers less.
|
|
116
116
|
|
|
117
117
|
### Configuration
|
|
118
118
|
|
|
@@ -152,13 +152,19 @@ There are seven options. That is the whole surface.
|
|
|
152
152
|
|
|
153
153
|
## Working on the gem itself
|
|
154
154
|
|
|
155
|
+
The development toolchain is pinned to Ruby 4.0.5 in `mise.toml`. Do not use
|
|
156
|
+
macOS's `/usr/bin/ruby`. In shells, hooks, or automation where mise activation
|
|
157
|
+
is uncertain, explicitly prepend the configured Ruby, for example
|
|
158
|
+
`PATH="$(mise where ruby)/bin:$PATH" bundle exec rake test`.
|
|
159
|
+
|
|
155
160
|
```bash
|
|
156
161
|
bundle exec rake test # minitest, dummy app under test/dummy
|
|
162
|
+
bundle exec rake test:system # real Next/Back/finish flow in headless Chrome
|
|
157
163
|
bundle exec rubocop # must be clean
|
|
158
164
|
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
159
165
|
```
|
|
160
166
|
|
|
161
|
-
Layout: `app/` controllers, `
|
|
167
|
+
Layout: `app/` controllers, `Tour`, dashboard views · `lib/product_tours/` config, widget JS, seeds, engine, CSP patch · `lib/generators/product_tours/install/` the one generator · `config/locales/` 26 locales · `test/` minitest with `test/dummy` as the host app.
|
|
162
168
|
|
|
163
169
|
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
164
170
|
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,63 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.0] - 2026-08-11
|
|
6
|
+
|
|
7
|
+
- **The record is a tour, all the way down.** `ProductTours::Post` is now
|
|
8
|
+
`ProductTours::Tour`; fresh installs create `product_tours_tours`, linked
|
|
9
|
+
steps use `action_tour_key`, lifecycle payloads use `tour_id`, the widget
|
|
10
|
+
resolves `/widget/tour`, and dashboard resources live under `/tours`. There
|
|
11
|
+
is deliberately no `Post` compatibility alias: v1 is the point where the
|
|
12
|
+
public vocabulary becomes honest and then freezes under semantic versioning.
|
|
13
|
+
- Existing 0.3.x installs must rename the `product_tours_posts` table to
|
|
14
|
+
`product_tours_tours` and its `action_post_key` column to `action_tour_key`
|
|
15
|
+
before booting 1.0.0. Host code must replace `ProductTours::Post`, old
|
|
16
|
+
dashboard paths, and the old `post_id` lifecycle payload key. Fresh installs
|
|
17
|
+
need no upgrade migration.
|
|
18
|
+
- Added one headless-Chrome system test that opens a linked walkthrough, moves
|
|
19
|
+
Next and Back, and finishes it. Capybara and Selenium are development/test
|
|
20
|
+
dependencies only; the runtime gem API and dependency surface are unchanged.
|
|
21
|
+
- Refreshed the PRD's shipped version, database-index contract, acceptance
|
|
22
|
+
wording, compatibility wording, and the missing release comparison links.
|
|
23
|
+
- **Uploaded videos now stay behind the engine gate.** The media controller
|
|
24
|
+
streams full and byte-range responses directly after authorization instead of
|
|
25
|
+
redirecting to a reusable signed Active Storage blob URL. Hosts gain the
|
|
26
|
+
privacy promised by the existing docs without losing playback or seeking.
|
|
27
|
+
- **Hosts that load Action View from an initializer boot normally.**
|
|
28
|
+
`WidgetHelper` is required from `lib` before the engine registers its Action
|
|
29
|
+
View hook; a dummy-host initializer reproduces that early load on every test
|
|
30
|
+
boot. `product_tours_tag` is unchanged.
|
|
31
|
+
- The documented model, configuration, route/helper, key, event, and callback
|
|
32
|
+
surface is now the stable 1.x API. Added a security policy with explicit
|
|
33
|
+
content, event, deletion, and retention boundaries.
|
|
34
|
+
- Supported combinations are Ruby 3.2 through 4.0 and Rails 7.1 through 8.1.
|
|
35
|
+
Rails now has a `< 9` upper bound. Release validation builds the gem, verifies
|
|
36
|
+
RubyGems indexing, and creates the GitHub Release. Generator tests write only
|
|
37
|
+
under ignored root `tmp/`, so tests cannot contaminate later lint runs.
|
|
38
|
+
|
|
39
|
+
## [0.3.3] - 2026-08-08
|
|
40
|
+
|
|
41
|
+
- **The demo walkthrough is now a tour of Product Tours itself.** Its linked
|
|
42
|
+
steps explain host-owned triggers, post chaining, optional video, drafts,
|
|
43
|
+
locale-stable keys, lifecycle events, direct and uploaded video delivery, and
|
|
44
|
+
completion. The standalone buttons still exercise every provider, a draft,
|
|
45
|
+
a missing key, and an action URL, while making the purpose of each case clear.
|
|
46
|
+
- English, French, and Bulgarian demo posts carry the educational copy. Rich
|
|
47
|
+
descriptions are added when Action Text is installed; title-only installs
|
|
48
|
+
still get the guided sequence. The provider videos remain generic examples.
|
|
49
|
+
- `product_tours:seed_demo` preserves demo keys and refreshes their content, so
|
|
50
|
+
rerunning it overwrites edits to those gem-owned posts. Tutorials with other
|
|
51
|
+
keys are not touched.
|
|
52
|
+
|
|
53
|
+
## [0.3.2] - 2026-08-08
|
|
54
|
+
|
|
55
|
+
- **Video tutorials are comfortably watchable without entering full screen.**
|
|
56
|
+
Text-only dialogs remain compact at 440px, while a tutorial containing video
|
|
57
|
+
automatically expands to 760px on desktop. Once a video appears in an open
|
|
58
|
+
walkthrough, the dialog stays wide for its remaining steps so the layout does
|
|
59
|
+
not jump back and forth. Mobile dialogs remain full-screen. There is no new
|
|
60
|
+
configuration or API to adopt.
|
|
61
|
+
|
|
5
62
|
## [0.3.1] - 2026-08-08
|
|
6
63
|
|
|
7
64
|
- **Hosts can handle lifecycle events with their own request context.** Set
|
|
@@ -13,7 +70,7 @@
|
|
|
13
70
|
are logged and never turn a working tutorial action into a visitor-facing
|
|
14
71
|
failure.
|
|
15
72
|
|
|
16
|
-
## 0.3.0
|
|
73
|
+
## [0.3.0] - 2026-08-05
|
|
17
74
|
|
|
18
75
|
- **One design system across the family.** The stylesheet now opens with a
|
|
19
76
|
shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
|
|
@@ -38,7 +95,17 @@
|
|
|
38
95
|
button on one line, and a `code.key` truncates inside a list row instead of
|
|
39
96
|
wrapping over three lines.
|
|
40
97
|
|
|
41
|
-
## 0.2.
|
|
98
|
+
## [0.2.2] - 2026-08-04
|
|
99
|
+
|
|
100
|
+
- Restored the intended dashboard width after CSS scoping and documented the
|
|
101
|
+
shared five-gem family conventions.
|
|
102
|
+
|
|
103
|
+
## [0.2.1] - 2026-08-04
|
|
104
|
+
|
|
105
|
+
- Restored the dashboard layout rules that the new scoped wrapper had
|
|
106
|
+
accidentally overridden.
|
|
107
|
+
|
|
108
|
+
## [0.2.0] - 2026-08-04
|
|
42
109
|
|
|
43
110
|
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
|
|
44
111
|
script were declared in the gem's layout, so replacing that layout dropped
|
|
@@ -133,7 +200,15 @@
|
|
|
133
200
|
- Removed generic tutorial duplication now that translations provide the only
|
|
134
201
|
intentional content-copying workflow.
|
|
135
202
|
|
|
136
|
-
[Unreleased]: https://github.com/yshmarov/product_tours/compare/
|
|
203
|
+
[Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.0.0...HEAD
|
|
204
|
+
[1.0.0]: https://github.com/yshmarov/product_tours/compare/v0.3.3...v1.0.0
|
|
205
|
+
[0.3.3]: https://github.com/yshmarov/product_tours/compare/v0.3.2...v0.3.3
|
|
206
|
+
[0.3.2]: https://github.com/yshmarov/product_tours/compare/v0.3.1...v0.3.2
|
|
137
207
|
[0.3.1]: https://github.com/yshmarov/product_tours/compare/v0.3.0...v0.3.1
|
|
208
|
+
[0.3.0]: https://github.com/yshmarov/product_tours/compare/v0.2.2...v0.3.0
|
|
209
|
+
[0.2.2]: https://github.com/yshmarov/product_tours/compare/v0.2.1...v0.2.2
|
|
210
|
+
[0.2.1]: https://github.com/yshmarov/product_tours/compare/v0.2.0...v0.2.1
|
|
211
|
+
[0.2.0]: https://github.com/yshmarov/product_tours/compare/v0.1.2...v0.2.0
|
|
212
|
+
[0.1.2]: https://github.com/yshmarov/product_tours/compare/v0.1.1...v0.1.2
|
|
138
213
|
[0.1.1]: https://github.com/yshmarov/product_tours/compare/v0.1.0...v0.1.1
|
|
139
214
|
[0.1.0]: https://github.com/yshmarov/product_tours/releases/tag/v0.1.0
|
data/README.md
CHANGED
|
@@ -51,7 +51,7 @@ small set of working tutorials so you can try the modal immediately.
|
|
|
51
51
|
> The dashboard defaults to **development only**. Set `authorize_admin` before
|
|
52
52
|
> deploying it — see [Configure](#configure).
|
|
53
53
|
|
|
54
|
-
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only for uploaded videos · Action
|
|
54
|
+
Ruby >= 3.2 · Rails >= 7.1 and < 9 · Active Storage only for uploaded videos · Action
|
|
55
55
|
Text only for rich descriptions.
|
|
56
56
|
|
|
57
57
|
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
@@ -92,8 +92,11 @@ the things it should not do. It ships inside the gem, so
|
|
|
92
92
|
|
|
93
93
|
<img src="docs/screenshots/03-mobile.jpg" alt="The product tutorial modal filling a mobile viewport with a video and primary action" width="390">
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
95
|
+
Text-only tutorials stay compact at 440px on desktop. A tutorial with video
|
|
96
|
+
automatically opens at up to 760px, and a walkthrough stays at that size after
|
|
97
|
+
its first video so the layout does not jump between steps. On screens up to
|
|
98
|
+
480px the modal becomes a full-screen sheet, respects safe areas, and follows
|
|
99
|
+
`visualViewport` while the mobile keyboard is open.
|
|
97
100
|
|
|
98
101
|
## Why a gem
|
|
99
102
|
|
|
@@ -210,7 +213,7 @@ key. Running the task again updates those records instead of duplicating them.
|
|
|
210
213
|
<button type="button" data-product-tour="demo_direct_video">Open the direct video tutorial</button>
|
|
211
214
|
<button type="button" data-product-tour="demo_getting_started">Open the getting started guide</button>
|
|
212
215
|
<button type="button" data-product-tour="demo_draft">Try an unpublished tutorial</button>
|
|
213
|
-
<button type="button" data-product-tour="
|
|
216
|
+
<button type="button" data-product-tour="demo_missing_tour">Try a missing tutorial</button>
|
|
214
217
|
</div>
|
|
215
218
|
<%= product_tours_tag %>
|
|
216
219
|
```
|
|
@@ -293,7 +296,10 @@ table. It emits three Rails notifications:
|
|
|
293
296
|
| `product_tours.dismissed` | The visitor closed it before using the primary action |
|
|
294
297
|
| `product_tours.completed` | The visitor used the primary action |
|
|
295
298
|
|
|
296
|
-
Payload: `
|
|
299
|
+
Payload: `tour_id`, `key`, `locale`, query-free `page_url`, and `source`.
|
|
300
|
+
The `source` vocabulary is also stable: viewed events use `modal`,
|
|
301
|
+
`linked_tour`, or `back`; completed events use `action` or `linked_tour`; and
|
|
302
|
+
dismissed events use `modal`.
|
|
297
303
|
|
|
298
304
|
Bridge them to Ahoy—or anything else—in your host app:
|
|
299
305
|
|
|
@@ -332,8 +338,51 @@ Invalid, missing, draft, or disabled keys open nothing for the visitor.
|
|
|
332
338
|
|
|
333
339
|
No end user gets a Rails error page because somebody renamed a tutorial.
|
|
334
340
|
|
|
341
|
+
## Compatibility and public API
|
|
342
|
+
|
|
343
|
+
The following are the model and integration contracts that 1.x will keep
|
|
344
|
+
stable under semantic versioning:
|
|
345
|
+
|
|
346
|
+
- `ProductTours::Tour`, including `key`, `locale`, `status`, `title`,
|
|
347
|
+
`video_url`, `video_metadata`, `action_label`, `action_url`,
|
|
348
|
+
`action_tour_key`, timestamps, `STATUSES`, status methods/scopes,
|
|
349
|
+
`newest_first`, `action_tour`, `description_supported?`,
|
|
350
|
+
`video_upload_supported?`, and optional rich-description/video behavior.
|
|
351
|
+
- `ProductTours.configure` and the documented configuration options.
|
|
352
|
+
- `mount_product_tours`, `product_tours_tag`, and `data-product-tour="key"`.
|
|
353
|
+
- The tutorial key grammar and per-locale uniqueness contract.
|
|
354
|
+
- `product_tours.viewed`, `product_tours.dismissed`, and
|
|
355
|
+
`product_tours.completed`, with `tour_id`, `key`, `locale`, query-free
|
|
356
|
+
`page_url`, and the documented `source` values.
|
|
357
|
+
- `config.on_event(name, payload, request)` and
|
|
358
|
+
`product_tours.unresolved_trigger`, whose payload has `key`, `locale`,
|
|
359
|
+
query-free `page_url`, and one of `invalid_key`, `missing`, `unpublished`, or
|
|
360
|
+
`disabled` as its `reason`.
|
|
361
|
+
|
|
362
|
+
Dashboard controllers, view partials, CSS classes, generated HTML, and widget
|
|
363
|
+
implementation objects are private. Incompatible changes to the public list
|
|
364
|
+
above wait for a new major version; a deprecation normally ships first.
|
|
365
|
+
|
|
366
|
+
### Upgrading from 0.3.x
|
|
367
|
+
|
|
368
|
+
Version 1.0.0 is the deliberate terminology break from the pre-1.0 API.
|
|
369
|
+
Existing installs must rename their generated table and linked-step column:
|
|
370
|
+
|
|
371
|
+
```ruby
|
|
372
|
+
rename_table :product_tours_posts, :product_tours_tours
|
|
373
|
+
rename_column :product_tours_tours, :action_post_key, :action_tour_key
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Replace `ProductTours::Post` with `ProductTours::Tour`, `post_id` event payloads
|
|
377
|
+
with `tour_id`, and any dashboard links under `/product_tours/posts` with
|
|
378
|
+
`/product_tours/tours`. Fresh installs already generate the new schema.
|
|
379
|
+
|
|
335
380
|
## Security
|
|
336
381
|
|
|
382
|
+
See [SECURITY.md](https://github.com/yshmarov/product_tours/blob/main/SECURITY.md)
|
|
383
|
+
for supported versions, private vulnerability reporting, the stored-data and
|
|
384
|
+
lifecycle-event boundary, and deletion/retention guidance.
|
|
385
|
+
|
|
337
386
|
- Admin authorization runs server-side on every dashboard request.
|
|
338
387
|
- Public resolution returns only published tutorials allowed by `enabled`.
|
|
339
388
|
- Video providers are an HTTPS allowlist; unsupported URLs render no iframe.
|
|
@@ -343,6 +392,11 @@ No end user gets a Rails error page because somebody renamed a tutorial.
|
|
|
343
392
|
`frame-src` without replacing the host policy.
|
|
344
393
|
- For direct videos on another origin, allow that origin in the host app's
|
|
345
394
|
`media-src` policy.
|
|
395
|
+
- Uploaded videos stream from the gated engine route with byte-range support and
|
|
396
|
+
private, non-stored responses; no reusable signed blob URL is exposed.
|
|
397
|
+
- The host owns retention and deletion. Remove a row with
|
|
398
|
+
`ProductTours::Tour.find(id).destroy!`, then apply object-store, backup,
|
|
399
|
+
analytics, cache, and export retention policies separately.
|
|
346
400
|
|
|
347
401
|
## What it doesn't do
|
|
348
402
|
|
|
@@ -357,10 +411,15 @@ stack owns persistence and reporting. The gem stays small enough to understand.
|
|
|
357
411
|
|
|
358
412
|
```bash
|
|
359
413
|
bundle exec rake test
|
|
414
|
+
bundle exec rake test:system
|
|
360
415
|
bundle exec rubocop
|
|
361
416
|
```
|
|
362
417
|
|
|
363
|
-
|
|
418
|
+
The normal suite covers the Rails engine contract. The system task drives the
|
|
419
|
+
real widget through Next, Back, and finish in headless Chrome. CI runs Rails
|
|
420
|
+
7.1, 7.2, 8.0, and 8.1 against Ruby 3.2 through 4.0, plus that browser test once
|
|
421
|
+
on Ruby 4.0. Rails 9 is outside the 1.x dependency contract until it passes the
|
|
422
|
+
suite.
|
|
364
423
|
|
|
365
424
|
Bug reports and pull requests are welcome. The most useful report is a real
|
|
366
425
|
Rails app and the exact point where installation or authoring felt confusing.
|
data/Rakefile
CHANGED
|
@@ -5,7 +5,15 @@ require 'rake/testtask'
|
|
|
5
5
|
|
|
6
6
|
Rake::TestTask.new(:test) do |task|
|
|
7
7
|
task.libs << 'test'
|
|
8
|
-
task.
|
|
8
|
+
task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*')
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
namespace :test do
|
|
12
|
+
desc 'Run browser (system) tests'
|
|
13
|
+
Rake::TestTask.new(:system) do |task|
|
|
14
|
+
task.libs << 'test'
|
|
15
|
+
task.test_files = FileList['test/system/**/*_test.rb']
|
|
16
|
+
end
|
|
9
17
|
end
|
|
10
18
|
|
|
11
19
|
task default: :test
|
data/SECURITY.md
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Security policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are released for the latest 1.x version. If a report also
|
|
6
|
+
affects the newest 0.x release, a backport may be published when the fix is
|
|
7
|
+
small and the affected application cannot upgrade immediately.
|
|
8
|
+
|
|
9
|
+
## Reporting a vulnerability
|
|
10
|
+
|
|
11
|
+
Please do not open a public issue for a vulnerability. Use
|
|
12
|
+
[GitHub private vulnerability reporting](https://github.com/yshmarov/product_tours/security/advisories/new)
|
|
13
|
+
and include the affected version, a minimal reproduction, impact, and any known
|
|
14
|
+
workaround. Credentials, session cookies, private application URLs, customer
|
|
15
|
+
content, uploaded videos, and production database contents should not be
|
|
16
|
+
included.
|
|
17
|
+
|
|
18
|
+
Public disclosure should wait until a fixed version is available and affected
|
|
19
|
+
users have had a reasonable opportunity to upgrade.
|
|
20
|
+
|
|
21
|
+
## Data and deployment boundary
|
|
22
|
+
|
|
23
|
+
The public widget resolves only published tours allowed by `config.enabled`,
|
|
24
|
+
which defaults to everyone because showing tutorials in production is the gem's
|
|
25
|
+
purpose. The dashboard is separately protected by `authorize_admin` and fails
|
|
26
|
+
closed outside development until the host explicitly grants access.
|
|
27
|
+
|
|
28
|
+
A tour can store its key, locale, status, title, video URL and metadata, primary
|
|
29
|
+
action label/destination, timestamps, optional Action Text description, and an
|
|
30
|
+
optional Active Storage video. Uploaded videos stream through the engine's
|
|
31
|
+
authorization gate with private, non-stored responses; the route never redirects
|
|
32
|
+
to a reusable signed blob URL. Hosts still own the storage service, bucket
|
|
33
|
+
policy, backups, retention period, access controls, and incident response.
|
|
34
|
+
|
|
35
|
+
The gem emits lifecycle events containing a tour id, key, locale, query-free
|
|
36
|
+
page URL, and source, but does not persist visitor events, identity, progress,
|
|
37
|
+
or completion. A host's `on_event` hook receives the raw request; anything it
|
|
38
|
+
derives or persists is part of the host application's own privacy boundary.
|
|
39
|
+
|
|
40
|
+
Delete a tour with ordinary Active Record when an application policy requires
|
|
41
|
+
erasure:
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
ProductTours::Tour.find(id).destroy!
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
That removes the record and lets Rails apply the configured Action Text and
|
|
48
|
+
Active Storage attachment lifecycles. Confirm deletion separately in object
|
|
49
|
+
storage, replicas, backups, caches, analytics, and exports; deleting the live
|
|
50
|
+
row does not rewrite them.
|
|
@@ -30,10 +30,10 @@ module ProductTours
|
|
|
30
30
|
ProductTours.locale(request)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def
|
|
33
|
+
def find_tour_by_locale(scope, key)
|
|
34
34
|
locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
|
|
35
|
-
|
|
36
|
-
locales.filter_map { |locale|
|
|
35
|
+
tours = scope.where(locale: locales, key: key).index_by(&:locale)
|
|
36
|
+
locales.filter_map { |locale| tours[locale] }.first
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def clean_page_url(value)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
|
-
# Root of the STAFF surface: the
|
|
4
|
+
# Root of the STAFF surface: the tour editor and its list.
|
|
5
5
|
#
|
|
6
6
|
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
7
|
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ProductTours
|
|
4
|
+
class InteractionsController < ApplicationController
|
|
5
|
+
SIGNALS = %w[viewed dismissed completed].freeze
|
|
6
|
+
|
|
7
|
+
def resolve
|
|
8
|
+
key = params[:key].to_s
|
|
9
|
+
return handle_unresolved_trigger(key, :disabled) unless ProductTours.enabled?(request)
|
|
10
|
+
return handle_unresolved_trigger(key, :invalid_key) unless key.match?(Tour::KEY_FORMAT)
|
|
11
|
+
|
|
12
|
+
tour = find_tour_by_locale(Tour.all, key)
|
|
13
|
+
return handle_unresolved_trigger(key, :missing) unless tour
|
|
14
|
+
return handle_unresolved_trigger(key, :unpublished) unless tour.published?
|
|
15
|
+
|
|
16
|
+
render json: tour_payload(tour)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def signal
|
|
20
|
+
return head :forbidden unless ProductTours.enabled?(request)
|
|
21
|
+
|
|
22
|
+
action = params[:event_action].to_s
|
|
23
|
+
return head :unprocessable_entity unless SIGNALS.include?(action)
|
|
24
|
+
|
|
25
|
+
tour = find_tour_by_locale(Tour.published, params[:key].to_s)
|
|
26
|
+
return head :not_found unless tour
|
|
27
|
+
|
|
28
|
+
payload = {
|
|
29
|
+
tour_id: tour.id,
|
|
30
|
+
key: tour.key,
|
|
31
|
+
locale: tour.locale,
|
|
32
|
+
page_url: clean_page_url(params[:page_url]),
|
|
33
|
+
source: params[:source].to_s.presence
|
|
34
|
+
}
|
|
35
|
+
event_name = "product_tours.#{action}"
|
|
36
|
+
ActiveSupport::Notifications.instrument(event_name, payload)
|
|
37
|
+
notify_host(event_name, payload)
|
|
38
|
+
head :no_content
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def notify_host(event_name, payload)
|
|
44
|
+
ProductTours.config.on_event.call(event_name, payload, request)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
Rails.logger.error("product_tours: on_event hook raised #{e.class}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def tour_payload(tour)
|
|
50
|
+
video = tour.resolved_video
|
|
51
|
+
video[:url] = media_path(tour) if video&.dig(:kind) == 'upload'
|
|
52
|
+
{
|
|
53
|
+
key: tour.key,
|
|
54
|
+
locale: tour.locale,
|
|
55
|
+
title: tour.title,
|
|
56
|
+
descriptionHtml: description_html(tour),
|
|
57
|
+
video: video,
|
|
58
|
+
action: {
|
|
59
|
+
label: tour.action_label.presence || default_action_label(tour),
|
|
60
|
+
url: tour.action_url.presence,
|
|
61
|
+
tourKey: tour.action_tour_key.presence
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def default_action_label(tour)
|
|
67
|
+
key = tour.action_tour_key.present? ? :next : :done
|
|
68
|
+
I18n.t(key, scope: :product_tours, default: key.to_s.humanize)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def description_html(tour)
|
|
72
|
+
return unless Tour.description_supported? && tour.description.present?
|
|
73
|
+
|
|
74
|
+
tour.description.body.to_s
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -2,13 +2,32 @@
|
|
|
2
2
|
|
|
3
3
|
module ProductTours
|
|
4
4
|
class MediaController < ApplicationController
|
|
5
|
+
include ActiveStorage::Streaming if defined?(::ActiveStorage::Streaming)
|
|
6
|
+
|
|
5
7
|
def show
|
|
6
|
-
|
|
7
|
-
allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) &&
|
|
8
|
+
tour = Tour.find(params[:id])
|
|
9
|
+
allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && tour.published?)
|
|
8
10
|
return head :forbidden unless allowed
|
|
9
|
-
return head :not_found unless
|
|
11
|
+
return head :not_found unless tour.uploaded_video?
|
|
12
|
+
|
|
13
|
+
stream(tour.video.blob)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
10
17
|
|
|
11
|
-
|
|
18
|
+
# Keep the browser on the authorized engine URL while retaining the byte
|
|
19
|
+
# range behavior video playback and seeking need. Active Storage's own
|
|
20
|
+
# streaming concern supports both full and ranged responses without a
|
|
21
|
+
# permanent signed blob URL escaping this controller's gate.
|
|
22
|
+
def stream(blob)
|
|
23
|
+
response.headers['Cache-Control'] = 'private, no-store'
|
|
24
|
+
if request.headers['Range'].present?
|
|
25
|
+
send_blob_byte_range_data blob, request.headers['Range']
|
|
26
|
+
else
|
|
27
|
+
response.headers['Accept-Ranges'] = 'bytes'
|
|
28
|
+
response.headers['Content-Length'] = blob.byte_size.to_s
|
|
29
|
+
send_blob_stream blob, disposition: 'inline'
|
|
30
|
+
end
|
|
12
31
|
end
|
|
13
32
|
end
|
|
14
33
|
end
|