product_tours 0.3.3 → 1.1.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/AGENTS.md +11 -5
  3. data/CHANGELOG.md +75 -3
  4. data/README.md +66 -4
  5. data/Rakefile +17 -1
  6. data/SECURITY.md +50 -0
  7. data/app/controllers/concerns/product_tours/request_context.rb +3 -3
  8. data/app/controllers/product_tours/dashboard_controller.rb +1 -1
  9. data/app/controllers/product_tours/interactions_controller.rb +77 -0
  10. data/app/controllers/product_tours/media_controller.rb +23 -4
  11. data/app/controllers/product_tours/tours_controller.rb +203 -52
  12. data/app/helpers/product_tours/{posts_helper.rb → tours_helper.rb} +1 -1
  13. data/app/models/product_tours/{post.rb → tour.rb} +22 -15
  14. data/app/views/product_tours/{posts → tours}/_form.html.erb +34 -34
  15. data/app/views/product_tours/{posts/_post_panel.html.erb → tours/_tour_panel.html.erb} +33 -33
  16. data/app/views/product_tours/{posts → tours}/edit.html.erb +3 -3
  17. data/app/views/product_tours/{posts → tours}/index.html.erb +23 -23
  18. data/app/views/product_tours/tours/new.html.erb +7 -0
  19. data/app/views/product_tours/{posts → tours}/show.html.erb +2 -2
  20. data/config/routes.rb +4 -4
  21. data/docs/ractors.md +138 -0
  22. data/lib/generators/product_tours/install/install_generator.rb +3 -3
  23. data/lib/generators/product_tours/install/templates/{create_product_tours_posts.rb.tt → create_product_tours_tours.rb.tt} +5 -5
  24. data/lib/product_tours/dashboard.css +1 -1
  25. data/lib/product_tours/dashboard.js +16 -16
  26. data/lib/product_tours/engine.rb +12 -0
  27. data/lib/product_tours/ractor_support.rb +519 -0
  28. data/lib/product_tours/seeds.rb +22 -22
  29. data/lib/product_tours/version.rb +1 -1
  30. data/lib/product_tours/widget.js +26 -26
  31. data/lib/product_tours/widget.rb +3 -3
  32. data/{app/helpers → lib}/product_tours/widget_helper.rb +4 -0
  33. data/lib/product_tours.rb +2 -0
  34. data/lib/tasks/product_tours_tasks.rake +3 -3
  35. metadata +22 -13
  36. data/app/controllers/product_tours/posts_controller.rb +0 -228
  37. 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: 397e2fbd273b707e9a16302852704ed8e5cc6daf410f3255fe3fa819047163b8
4
- data.tar.gz: c040cac638d52a7484f4ef109732211e181f0b103bdbcd938f0d6c3c7b8d10e1
3
+ metadata.gz: 5493d4177a606096eb7c630c0dd4fbab70a5b68ec013e3033b58217ec7de5c02
4
+ data.tar.gz: f150d164554a4d4a710214b2924fb191aa28e8aee6f87f813cb5c3418e6c9efc
5
5
  SHA512:
6
- metadata.gz: b22e2391759e9a49d90728b9709650e5f0f2d1001fdf36d71e4c8d2c494e853007f70794b93c92762363502872f024970cf62660fa4639702aac82c854b2f9ab
7
- data.tar.gz: e2ceef66890c00c8a5f3f9293f946e35c366215b08a584a52e49408cb858f245affe2eab62caece0f1b7286f5b5944da9358f3315777b95208e962e715b83b2f
6
+ metadata.gz: 137467ccfedd59ff4abaa2b4a8f35735b8b02bcde9a9439901905191036d419b221f810f92d57ae66737d45ed492b7b5583e0043006ec68eb7c193bac8fa60d5
7
+ data.tar.gz: 91993e998b3d5fa607b35d38d20c58c3dfe6963d9a3860df6dc2dd9c8f872f496d7bc501e3ae26301bf843987fd53a532fe2eaacd1785c9b6dc7fe093df3776f
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 (`product_tours_posts`), 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.
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 `product_tours_posts`, 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.
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 (`Post.video_upload_supported?`, `Post.description_supported?`) and simply offers less.
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, `Post`, 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.
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,61 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-10-18
6
+
7
+ - Add Rails edge CI and experimental Ruby 4.0+ Ractor request support. The host
8
+ must opt into `Rails.application.ractorize!` and use the upstream Rack/I18n
9
+ dependencies documented in `docs/ractors.md`.
10
+ - Exercise actual multipart uploads and private downloads in worker Ractors
11
+ when Active Storage is installed, alongside the optional-dependency-free host.
12
+ - With Action Text, dashboard/editor and tour-content responses run on the
13
+ main Ractor because Nokogiri is not Ractor safe. Those paths buffer request
14
+ bodies and responses and do not gain parallel execution; other paths stay
15
+ on workers.
16
+ - Prepare immutable widget assets and a frozen configuration snapshot at
17
+ Ractorization. Callbacks still run for each request, with isolated captures;
18
+ callbacks that capture mutable state fail at boot with the setting name.
19
+ Normal Rails boots retain mutable configuration and lazy asset loading.
20
+ - Bridge framework operations that are not yet Ractor safe (mounted URL
21
+ generation, digests/key derivation and attribute factories) to the main
22
+ Ractor. This adds dispatch overhead in Ractor mode; it is compatibility
23
+ support, not a claim of improved throughput. Existing Rails 7.1–8.1 installs
24
+ need no migration or initializer changes.
25
+
26
+ ## [1.0.0] - 2026-08-11
27
+
28
+ - **The record is a tour, all the way down.** `ProductTours::Post` is now
29
+ `ProductTours::Tour`; fresh installs create `product_tours_tours`, linked
30
+ steps use `action_tour_key`, lifecycle payloads use `tour_id`, the widget
31
+ resolves `/widget/tour`, and dashboard resources live under `/tours`. There
32
+ is deliberately no `Post` compatibility alias: v1 is the point where the
33
+ public vocabulary becomes honest and then freezes under semantic versioning.
34
+ - Existing 0.3.x installs must rename the `product_tours_posts` table to
35
+ `product_tours_tours` and its `action_post_key` column to `action_tour_key`
36
+ before booting 1.0.0. Host code must replace `ProductTours::Post`, old
37
+ dashboard paths, and the old `post_id` lifecycle payload key. Fresh installs
38
+ need no upgrade migration.
39
+ - Added one headless-Chrome system test that opens a linked walkthrough, moves
40
+ Next and Back, and finishes it. Capybara and Selenium are development/test
41
+ dependencies only; the runtime gem API and dependency surface are unchanged.
42
+ - Refreshed the PRD's shipped version, database-index contract, acceptance
43
+ wording, compatibility wording, and the missing release comparison links.
44
+ - **Uploaded videos now stay behind the engine gate.** The media controller
45
+ streams full and byte-range responses directly after authorization instead of
46
+ redirecting to a reusable signed Active Storage blob URL. Hosts gain the
47
+ privacy promised by the existing docs without losing playback or seeking.
48
+ - **Hosts that load Action View from an initializer boot normally.**
49
+ `WidgetHelper` is required from `lib` before the engine registers its Action
50
+ View hook; a dummy-host initializer reproduces that early load on every test
51
+ boot. `product_tours_tag` is unchanged.
52
+ - The documented model, configuration, route/helper, key, event, and callback
53
+ surface is now the stable 1.x API. Added a security policy with explicit
54
+ content, event, deletion, and retention boundaries.
55
+ - Supported combinations are Ruby 3.2 through 4.0 and Rails 7.1 through 8.1.
56
+ Rails now has a `< 9` upper bound. Release validation builds the gem, verifies
57
+ RubyGems indexing, and creates the GitHub Release. Generator tests write only
58
+ under ignored root `tmp/`, so tests cannot contaminate later lint runs.
59
+
5
60
  ## [0.3.3] - 2026-08-08
6
61
 
7
62
  - **The demo walkthrough is now a tour of Product Tours itself.** Its linked
@@ -36,7 +91,7 @@
36
91
  are logged and never turn a working tutorial action into a visitor-facing
37
92
  failure.
38
93
 
39
- ## 0.3.0
94
+ ## [0.3.0] - 2026-08-05
40
95
 
41
96
  - **One design system across the family.** The stylesheet now opens with a
42
97
  shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
@@ -61,7 +116,17 @@
61
116
  button on one line, and a `code.key` truncates inside a list row instead of
62
117
  wrapping over three lines.
63
118
 
64
- ## 0.2.0
119
+ ## [0.2.2] - 2026-08-04
120
+
121
+ - Restored the intended dashboard width after CSS scoping and documented the
122
+ shared five-gem family conventions.
123
+
124
+ ## [0.2.1] - 2026-08-04
125
+
126
+ - Restored the dashboard layout rules that the new scoped wrapper had
127
+ accidentally overridden.
128
+
129
+ ## [0.2.0] - 2026-08-04
65
130
 
66
131
  - **`config.admin_layout` now works on its own.** The dashboard's stylesheet and
67
132
  script were declared in the gem's layout, so replacing that layout dropped
@@ -156,8 +221,15 @@
156
221
  - Removed generic tutorial duplication now that translations provide the only
157
222
  intentional content-copying workflow.
158
223
 
159
- [Unreleased]: https://github.com/yshmarov/product_tours/compare/v0.3.2...HEAD
224
+ [Unreleased]: https://github.com/yshmarov/product_tours/compare/v1.0.0...HEAD
225
+ [1.0.0]: https://github.com/yshmarov/product_tours/compare/v0.3.3...v1.0.0
226
+ [0.3.3]: https://github.com/yshmarov/product_tours/compare/v0.3.2...v0.3.3
160
227
  [0.3.2]: https://github.com/yshmarov/product_tours/compare/v0.3.1...v0.3.2
161
228
  [0.3.1]: https://github.com/yshmarov/product_tours/compare/v0.3.0...v0.3.1
229
+ [0.3.0]: https://github.com/yshmarov/product_tours/compare/v0.2.2...v0.3.0
230
+ [0.2.2]: https://github.com/yshmarov/product_tours/compare/v0.2.1...v0.2.2
231
+ [0.2.1]: https://github.com/yshmarov/product_tours/compare/v0.2.0...v0.2.1
232
+ [0.2.0]: https://github.com/yshmarov/product_tours/compare/v0.1.2...v0.2.0
233
+ [0.1.2]: https://github.com/yshmarov/product_tours/compare/v0.1.1...v0.1.2
162
234
  [0.1.1]: https://github.com/yshmarov/product_tours/compare/v0.1.0...v0.1.1
163
235
  [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
@@ -213,7 +213,7 @@ key. Running the task again updates those records instead of duplicating them.
213
213
  <button type="button" data-product-tour="demo_direct_video">Open the direct video tutorial</button>
214
214
  <button type="button" data-product-tour="demo_getting_started">Open the getting started guide</button>
215
215
  <button type="button" data-product-tour="demo_draft">Try an unpublished tutorial</button>
216
- <button type="button" data-product-tour="demo_missing_post">Try a missing tutorial</button>
216
+ <button type="button" data-product-tour="demo_missing_tour">Try a missing tutorial</button>
217
217
  </div>
218
218
  <%= product_tours_tag %>
219
219
  ```
@@ -296,7 +296,10 @@ table. It emits three Rails notifications:
296
296
  | `product_tours.dismissed` | The visitor closed it before using the primary action |
297
297
  | `product_tours.completed` | The visitor used the primary action |
298
298
 
299
- Payload: `post_id`, `key`, `locale`, query-free `page_url`, and `source`.
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`.
300
303
 
301
304
  Bridge them to Ahoy—or anything else—in your host app:
302
305
 
@@ -335,8 +338,51 @@ Invalid, missing, draft, or disabled keys open nothing for the visitor.
335
338
 
336
339
  No end user gets a Rails error page because somebody renamed a tutorial.
337
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
+
338
380
  ## Security
339
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
+
340
386
  - Admin authorization runs server-side on every dashboard request.
341
387
  - Public resolution returns only published tutorials allowed by `enabled`.
342
388
  - Video providers are an HTTPS allowlist; unsupported URLs render no iframe.
@@ -346,6 +392,11 @@ No end user gets a Rails error page because somebody renamed a tutorial.
346
392
  `frame-src` without replacing the host policy.
347
393
  - For direct videos on another origin, allow that origin in the host app's
348
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.
349
400
 
350
401
  ## What it doesn't do
351
402
 
@@ -360,10 +411,15 @@ stack owns persistence and reporting. The gem stays small enough to understand.
360
411
 
361
412
  ```bash
362
413
  bundle exec rake test
414
+ bundle exec rake test:system
363
415
  bundle exec rubocop
364
416
  ```
365
417
 
366
- CI runs Rails 7.1 / 7.2 / 8.0 / 8.1 against Ruby 3.2 / 3.3 / 3.4.
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.
367
423
 
368
424
  Bug reports and pull requests are welcome. The most useful report is a real
369
425
  Rails app and the exact point where installation or authoring felt confusing.
@@ -391,3 +447,9 @@ touch your own CSS — and migrations that follow your app's `primary_key_type`.
391
447
 
392
448
  MIT. If it saved you a subscription, a
393
449
  [⭐](https://github.com/yshmarov/product_tours) is a fair trade.
450
+
451
+ ## Rails edge and Ractors
452
+
453
+ Rails edge is tested alongside Rails 7.1–8.1. Experimental Ruby 4.0+ Ractor hosting
454
+ requires additional upstream dependencies and freezes configuration at startup.
455
+ See [the Ractor hosting guide](docs/ractors.md) for callbacks, requirements and checks.
data/Rakefile CHANGED
@@ -5,7 +5,23 @@ require 'rake/testtask'
5
5
 
6
6
  Rake::TestTask.new(:test) do |task|
7
7
  task.libs << 'test'
8
- task.pattern = 'test/**/*_test.rb'
8
+ task.test_files = FileList['test/**/*_test.rb'].exclude('test/system/**/*', 'test/ractor/**/*')
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
20
+
21
+ namespace :test do
22
+ desc 'Run isolated configuration and Rails edge Ractor request tests (Ruby 4.0+)'
23
+ task :ractor do
24
+ ruby '-Itest', 'test/ractor/configuration_test.rb'
25
+ ruby '-Itest', 'test/ractor/requests_test.rb'
26
+ end
27
+ end
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 find_post_by_locale(scope, key)
33
+ def find_tour_by_locale(scope, key)
34
34
  locales = [current_product_tours_locale, I18n.default_locale.to_s].uniq
35
- posts = scope.where(locale: locales, key: key).index_by(&:locale)
36
- locales.filter_map { |locale| posts[locale] }.first
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 post editor and its list.
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
- post = Post.find(params[:id])
7
- allowed = ProductTours.admin?(request) || (ProductTours.enabled?(request) && post.published?)
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 post.uploaded_video?
11
+ return head :not_found unless tour.uploaded_video?
12
+
13
+ stream(tour.video.blob)
14
+ end
15
+
16
+ private
10
17
 
11
- redirect_to main_app.rails_blob_path(post.video, disposition: 'inline')
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