ideasbugs 0.5.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 (51) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +114 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +339 -0
  5. data/Rakefile +19 -0
  6. data/app/controllers/ideasbugs/application_controller.rb +15 -0
  7. data/app/controllers/ideasbugs/feedbacks_controller.rb +156 -0
  8. data/app/controllers/ideasbugs/screenshots_controller.rb +27 -0
  9. data/app/helpers/ideasbugs/widget_helper.rb +18 -0
  10. data/app/models/ideasbugs/application_record.rb +7 -0
  11. data/app/models/ideasbugs/feedback.rb +30 -0
  12. data/app/views/ideasbugs/feedbacks/index.html.erb +78 -0
  13. data/app/views/ideasbugs/feedbacks/show.html.erb +63 -0
  14. data/app/views/layouts/ideasbugs/application.html.erb +76 -0
  15. data/config/locales/ideasbugs.ar.yml +24 -0
  16. data/config/locales/ideasbugs.bg.yml +24 -0
  17. data/config/locales/ideasbugs.bn.yml +24 -0
  18. data/config/locales/ideasbugs.de.yml +24 -0
  19. data/config/locales/ideasbugs.el.yml +24 -0
  20. data/config/locales/ideasbugs.en.yml +47 -0
  21. data/config/locales/ideasbugs.es.yml +24 -0
  22. data/config/locales/ideasbugs.fr.yml +24 -0
  23. data/config/locales/ideasbugs.hi.yml +24 -0
  24. data/config/locales/ideasbugs.hr.yml +24 -0
  25. data/config/locales/ideasbugs.id.yml +24 -0
  26. data/config/locales/ideasbugs.it.yml +24 -0
  27. data/config/locales/ideasbugs.ja.yml +24 -0
  28. data/config/locales/ideasbugs.ko.yml +24 -0
  29. data/config/locales/ideasbugs.lb.yml +24 -0
  30. data/config/locales/ideasbugs.nl.yml +24 -0
  31. data/config/locales/ideasbugs.pl.yml +24 -0
  32. data/config/locales/ideasbugs.pt.yml +24 -0
  33. data/config/locales/ideasbugs.ro.yml +24 -0
  34. data/config/locales/ideasbugs.ru.yml +24 -0
  35. data/config/locales/ideasbugs.th.yml +24 -0
  36. data/config/locales/ideasbugs.tr.yml +24 -0
  37. data/config/locales/ideasbugs.uk.yml +24 -0
  38. data/config/locales/ideasbugs.ur.yml +24 -0
  39. data/config/locales/ideasbugs.vi.yml +24 -0
  40. data/config/locales/ideasbugs.zh-CN.yml +24 -0
  41. data/config/routes.rb +12 -0
  42. data/lib/generators/ideasbugs/install/install_generator.rb +41 -0
  43. data/lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt +21 -0
  44. data/lib/generators/ideasbugs/install/templates/initializer.rb +47 -0
  45. data/lib/ideasbugs/configuration.rb +93 -0
  46. data/lib/ideasbugs/engine.rb +13 -0
  47. data/lib/ideasbugs/version.rb +5 -0
  48. data/lib/ideasbugs/widget.js +387 -0
  49. data/lib/ideasbugs/widget.rb +109 -0
  50. data/lib/ideasbugs.rb +34 -0
  51. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 03a9f3a848ba6fbdeba823dd0df27efe24a1fadb9c4856a91e76f46f0a16a409
4
+ data.tar.gz: 28f63f07db4e1fd53b37946a7ec372fd01221101a4ac51ee380c9b7399917c1c
5
+ SHA512:
6
+ metadata.gz: f4661d353dc2cad65748718937aeaf2d5f78be428aed86e636b1a51ec27f6199ee030f89c618cb00543aa5572568cf048b3f8ef455e66ee332b777586227f161
7
+ data.tar.gz: 96a3ecaca262a905312402e6494623d866b642c83ecad73da4fbf80bfde4beb85696abacb549789030570568b751449c2f64eac6cb895c38582e7c13c5132fec
data/CHANGELOG.md ADDED
@@ -0,0 +1,114 @@
1
+ # Changelog
2
+
3
+ ## 0.5.0 (2026-07-25)
4
+
5
+ **The gem is now `ideasbugs`** (formerly `feedback_engine`). Full rebrand:
6
+
7
+ - Gem name `ideasbugs`, module `Ideasbugs`, repository
8
+ github.com/yshmarov/ideasbugs.
9
+ - Table `ideasbugs_feedbacks` (was `feedback_engine_feedbacks`), helper
10
+ `ideasbugs_tag` (was `feedback_engine_tag`), I18n scope `ideasbugs.*`,
11
+ initializer `config/initializers/ideasbugs.rb`, generator
12
+ `ideasbugs:install`, widget data attributes `data-ideasbugs-*`.
13
+ - **Upgrading from feedback_engine:** swap the gem in your Gemfile, rename
14
+ the initializer and its `FeedbackEngine` constants to `Ideasbugs`, update
15
+ the layout tag/trigger attributes, remount the engine, and migrate:
16
+
17
+ ```ruby
18
+ rename_table :feedback_engine_feedbacks, :ideasbugs_feedbacks
19
+ # Existing screenshot attachments keep working after:
20
+ # UPDATE active_storage_attachments SET record_type = 'Ideasbugs::Feedback'
21
+ # WHERE record_type = 'FeedbackEngine::Feedback'
22
+ ```
23
+
24
+ Versions ≤ 0.4.2 remain published under the old name; entries below this
25
+ point describe releases made as `feedback_engine`.
26
+
27
+ ## 0.4.2 (2026-07-25)
28
+
29
+ - The default title CTA (`feedback_engine.title`, used as the modal heading
30
+ and by host-rendered triggers) now reads "Send bug/feature request" instead
31
+ of "Send feedback", localized in all 26 bundled languages.
32
+ - The test suite migrated from RSpec to Minitest (development-only change;
33
+ nothing in the shipped gem is affected). `bundle exec rake test` and
34
+ `rake test:system` replace the rspec commands.
35
+
36
+ ## 0.4.1 (2026-07-23)
37
+
38
+ - The widget JavaScript moved from `app/assets/` to `lib/` (it was always
39
+ inlined server-side, never served as an asset). Rails auto-registers every
40
+ engine's `app/assets/*` directory with the host's asset pipeline, so
41
+ Propshaft hosts were ingesting the file into their asset namespace under
42
+ the bare logical name `widget.js` — colliding with any other gem or host
43
+ file of the same name — and needlessly digesting a public copy at
44
+ precompile. The gem is now invisible to Sprockets/Propshaft entirely.
45
+ No behavior change.
46
+
47
+ ## 0.4.0 (2026-07-22)
48
+
49
+ - Removed the clipboard-paste / drag-and-drop / file-chips screenshot intake
50
+ added in 0.3.0 — pasted files all arrive as `image.png` and the extra UI
51
+ wasn't worth its complexity. Screenshots are back to a plain file input.
52
+
53
+ ## 0.3.0 (2026-07-22)
54
+
55
+ - Screenshots can be pasted (Cmd/Ctrl+V) or dragged & dropped anywhere on
56
+ the feedback form, not just picked via the file dialog. Selected files show
57
+ as removable chips; non-images are ignored and the configured maximum is
58
+ enforced at intake. (Removed again in 0.4.0.)
59
+ - The dialog traps Tab focus while open and is labelled for screen readers
60
+ (`aria-labelledby`).
61
+ - Dashboard search across message, author, and section (case-insensitive,
62
+ LIKE-wildcard-safe, works on SQLite/PostgreSQL/MySQL).
63
+ - A raising `on_submit` hook no longer turns a saved submission into a 500 —
64
+ the error is logged and the widget still gets its 201.
65
+ - Releases are automated via RubyGems trusted publishing: pushing a `v*` tag
66
+ builds and publishes the gem from CI (one-time setup on rubygems.org
67
+ required; see `.github/workflows/release.yml`).
68
+
69
+ ## 0.2.0 (2026-07-22)
70
+
71
+ Security:
72
+
73
+ - Dashboard screenshots are now streamed through an engine route gated by
74
+ `config.authorize_admin`, instead of linking public Active Storage blob
75
+ URLs. Screenshots can contain anything a user's screen showed; they are no
76
+ longer reachable without passing the dashboard's own authorization,
77
+ regardless of how the host configures blob access. (Also fixes broken
78
+ images in apps that lock their blob endpoints down.)
79
+
80
+ Added:
81
+
82
+ - Per-IP rate limiting on the submission endpoint via Rails' built-in
83
+ limiter (Rails 7.2+; no-op on 7.1). Default 10 submissions/minute; tune or
84
+ disable with `config.rate_limit`. Localized 429 message in all 26 languages.
85
+ - Browser-level test suite for the widget (Capybara + headless Chrome):
86
+ open/submit/validation/screenshot-attach/Escape/custom-trigger flows.
87
+ - CI now tests the full Rails support matrix: 7.1, 7.2, 8.0, and 8.1 across
88
+ Ruby 3.2–3.4.
89
+
90
+ ## 0.1.0 (2026-07-21)
91
+
92
+ Initial release.
93
+
94
+ - Drop-in feedback widget (`<%= feedback_engine_tag %>`): floating button +
95
+ self-styled modal with type, optional section, message, and optional
96
+ screenshots. Plain JavaScript, no build step, CSP-nonce aware, Turbo-safe,
97
+ follows system light/dark appearance and the app's `I18n.locale` (RTL
98
+ supported).
99
+ - `feedback_engine_feedbacks` table with kind, section, message, status
100
+ (open / in_review / resolved), page URL, user agent, and loose author
101
+ attribution.
102
+ - Screenshot uploads via Active Storage with server-side count / size /
103
+ content-type validation (configurable limits).
104
+ - Built-in triage dashboard at the mount path: status tabs with counts, type
105
+ filter, detail view with screenshots, status transitions, delete. Gated by
106
+ `config.authorize_admin` (development-only by default).
107
+ - Configuration hooks: `enabled`, `authorize_admin`, `current_user`,
108
+ `author_label`, `kinds`, `sections`, `screenshots` limits, `show_button`,
109
+ `button_label`, `mount_path`, `on_submit`.
110
+ - `feedback_engine:install` generator (initializer, migration, mount).
111
+ - Widget translations for English plus 25 more languages (ar, bg, bn, de, el,
112
+ es, fr, hi, hr, id, it, ja, ko, lb, nl, pl, pt, ro, ru, th, tr, uk, ur, vi,
113
+ zh-CN), with a parity spec keeping every locale's key set and interpolation
114
+ placeholders in sync.
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright 2026 Yaroslav Shmarov
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,339 @@
1
+ # ideasbugs
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/ideasbugs)](https://rubygems.org/gems/ideasbugs)
4
+ [![Downloads](https://img.shields.io/gem/dt/ideasbugs)](https://rubygems.org/gems/ideasbugs)
5
+ [![CI](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml/badge.svg)](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](MIT-LICENSE)
7
+
8
+ In-app product feedback collection for Rails. (Formerly published as
9
+ `feedback_engine` — see the [CHANGELOG](CHANGELOG.md) for the rename notes.)
10
+
11
+ `ideasbugs` adds a **"Send feedback"** widget to your app — bug reports,
12
+ feature requests, general comments, with optional screenshots — and stores
13
+ every submission in your own database. A minimal built-in dashboard lets you
14
+ browse and triage what users send. No third-party service, no data leaves your
15
+ app.
16
+
17
+ <picture>
18
+ <source media="(prefers-color-scheme: dark)" srcset="demo-widget-dark.png">
19
+ <img alt="The feedback widget: type, section, message, and screenshots" src="demo-widget-light.png" width="420">
20
+ </picture>
21
+
22
+ <picture>
23
+ <source media="(prefers-color-scheme: dark)" srcset="demo-dashboard-dark.png">
24
+ <img alt="The built-in triage dashboard: status tabs, filters, search" src="demo-dashboard-light.png" width="820">
25
+ </picture>
26
+
27
+ Both the widget and the dashboard follow the viewer's system appearance —
28
+ these screenshots are the same pages in light and dark mode.
29
+
30
+ ## Why not Canny (or another feedback SaaS)?
31
+
32
+ Hosted feedback tools are great until you notice the trade: your users'
33
+ feedback lives in someone else's database, behind someone else's login, at a
34
+ per-seat price. `ideasbugs` keeps the core loop — collect, triage,
35
+ resolve — inside your app: **your database, your users, your auth, one gem.**
36
+ Attribution plugs into whatever authentication you already have, gating is a
37
+ lambda, and submissions are ordinary ActiveRecord rows you can query, export,
38
+ or wire into Slack. (A public voting board à la Canny is on the
39
+ [roadmap](https://github.com/yshmarov/ideasbugs/issues/1).)
40
+
41
+ - **Zero UI dependencies.** The widget is plain JavaScript and styles itself.
42
+ No Tailwind, no Stimulus, no importmap, no build step. The dashboard renders
43
+ its own styles too.
44
+ - **One line in your layout.** `<%= ideasbugs_tag %>` and you're
45
+ collecting feedback.
46
+ - **Trigger it your way.** Use the built-in floating button, or hide it and
47
+ open the form from any element with a `data-ideasbugs-open` attribute.
48
+ - **Screenshots included.** Users can attach up to 3 images (via Active
49
+ Storage) — limits are configurable and enforced server-side.
50
+ - **Pluggable gating and attribution.** You decide who can send feedback, who
51
+ can read it, and how a submission is attributed to a user.
52
+ - **Localized.** The widget follows your app's `I18n.locale`; translations ship
53
+ for English plus 25 more languages — Arabic, Bengali, Bulgarian, Chinese
54
+ (Simplified), Croatian, Dutch, French, German, Greek, Hindi, Indonesian,
55
+ Italian, Japanese, Korean, Luxembourgish, Polish, Portuguese, Romanian,
56
+ Russian, Spanish, Thai, Turkish, Ukrainian, Urdu and Vietnamese — with
57
+ English fallbacks for everything else. RTL locales render mirrored.
58
+
59
+ ## How it works
60
+
61
+ 1. `ideasbugs_tag` renders a floating **Feedback** button (bottom-right).
62
+ 2. Clicking it opens a small self-styled modal: type (bug / feature / other),
63
+ an optional section select, a message, and optional screenshots.
64
+ 3. The form `POST`s to the mounted engine with the page URL, the user agent,
65
+ and (if you configure it) the current user — stored in the
66
+ `ideasbugs_feedbacks` table.
67
+ 4. You browse and triage submissions at the mount path (`/feedback`): status
68
+ tabs (open → in review → resolved), type filter, search, screenshots
69
+ inline.
70
+
71
+ ## Requirements
72
+
73
+ - Ruby >= 3.2
74
+ - Rails >= 7.1
75
+ - Active Storage (only if you want screenshot uploads)
76
+
77
+ ## Installation
78
+
79
+ ```ruby
80
+ # Gemfile
81
+ gem "ideasbugs"
82
+ ```
83
+
84
+ ```bash
85
+ bundle install
86
+ bin/rails generate ideasbugs:install
87
+ bin/rails db:migrate
88
+ ```
89
+
90
+ The generator:
91
+
92
+ - writes `config/initializers/ideasbugs.rb`,
93
+ - creates the `ideasbugs_feedbacks` migration,
94
+ - mounts the engine in `config/routes.rb`:
95
+
96
+ ```ruby
97
+ mount Ideasbugs::Engine => "/feedback"
98
+ ```
99
+
100
+ Then add the widget to your layout, right before `</body>`:
101
+
102
+ ```erb
103
+ <%= ideasbugs_tag %>
104
+ ```
105
+
106
+ > The widget reads the CSRF token from `<meta name="csrf-token">`, which
107
+ > `csrf_meta_tags` in your layout already provides in a standard Rails app.
108
+
109
+ Boot the app and look for the **Feedback** button in the bottom-right corner.
110
+ Submissions appear at [`/feedback`](http://localhost:3000/feedback) (dashboard
111
+ access defaults to development only — see below).
112
+
113
+ ## Configuration
114
+
115
+ Everything is optional; the defaults work out of the box.
116
+
117
+ ```ruby
118
+ # config/initializers/ideasbugs.rb
119
+ Ideasbugs.configure do |config|
120
+ # Who can send feedback. Return false to hide the widget and reject
121
+ # submissions for this request. Defaults to everyone.
122
+ config.enabled = ->(request) { true }
123
+
124
+ # Who can browse and triage feedback at the mount path.
125
+ # DEFAULTS TO DEVELOPMENT ONLY — override before deploying.
126
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
127
+
128
+ # Attribute feedback to a user (optional). Return an object responding to
129
+ # #id, or nil. Receives the request.
130
+ config.current_user = ->(request) { request.env["warden"]&.user }
131
+
132
+ # Label stored for the author and shown in the dashboard.
133
+ config.author_label = ->(user) { user.try(:email) }
134
+
135
+ # Feedback types users can pick from. Labels come from I18n
136
+ # (ideasbugs.kinds.<kind>).
137
+ config.kinds = %w[bug feature other]
138
+
139
+ # App areas shown as a select in the widget. Empty list hides the select.
140
+ config.sections = ["Dashboard", "Billing", "Settings"]
141
+
142
+ # Screenshot uploads (requires Active Storage).
143
+ config.screenshots = true
144
+ config.max_screenshots = 3
145
+ config.max_screenshot_size = 5.megabytes
146
+
147
+ # Per-IP throttle for the submission endpoint (Rails 7.2+; ignored on 7.1).
148
+ # Keyword arguments for Rails' rate limiter; nil disables throttling.
149
+ config.rate_limit = { to: 10, within: 1.minute }
150
+
151
+ # Show the floating feedback button. Set false and trigger the form from
152
+ # your own UI instead (see below).
153
+ config.show_button = true
154
+
155
+ # Fixed button text; leave nil to use the localized default.
156
+ config.button_label = nil
157
+
158
+ # Keep in sync with the `mount` in config/routes.rb.
159
+ config.mount_path = "/feedback"
160
+
161
+ # Called with each saved feedback — notify Slack, send an email, etc.
162
+ config.on_submit = ->(feedback) { FeedbackMailer.with(feedback:).new_feedback.deliver_later }
163
+ end
164
+ ```
165
+
166
+ ### Opening the form from your own UI
167
+
168
+ Prefer a nav item over the floating button? Add `data-ideasbugs-open` to
169
+ any element and (optionally) hide the button:
170
+
171
+ ```ruby
172
+ config.show_button = false # optional
173
+ ```
174
+
175
+ Gate the trigger with the same check the widget uses — `ideasbugs_tag`
176
+ renders nothing when `config.enabled` rejects the request, so an unguarded
177
+ trigger would be a dead button for those users. The gem ships a
178
+ `ideasbugs.title` label ("Send bug/feature request") in every bundled language, so
179
+ a localized trigger needs no keys of your own:
180
+
181
+ ```erb
182
+ <% if Ideasbugs.enabled?(request) %>
183
+ <button data-ideasbugs-open><%= t("ideasbugs.title") %></button>
184
+ <% end %>
185
+ ```
186
+
187
+ ### Loading the widget only where it's used
188
+
189
+ `ideasbugs_tag` in the layout puts the widget on every page. If your
190
+ only trigger lives on one page, render the tag on that page instead — for
191
+ example into the layout's `<head>` via `content_for`:
192
+
193
+ ```erb
194
+ <%# app/views/layouts/application.html.erb %>
195
+ <head>
196
+ ...
197
+ <%= yield :head %>
198
+ </head>
199
+
200
+ <%# app/views/users/show.html.erb — the page with the trigger %>
201
+ <% content_for :head, ideasbugs_tag %>
202
+ ```
203
+
204
+ This is Turbo-safe: the script installs its document-level listeners once per
205
+ session and re-renders on `turbo:load` (see [Turbo](#turbo)), whether it
206
+ arrives on the first page load or a later visit. The trade-off: a
207
+ `data-ideasbugs-open` element on a page that never rendered the tag
208
+ silently does nothing — if triggers may appear on several pages, keep the tag
209
+ in the layout.
210
+
211
+ ### Protecting the dashboard
212
+
213
+ The dashboard (index/show/triage) is gated by `config.authorize_admin`, which
214
+ defaults to **development only** so a fresh install can never leak feedback in
215
+ production. Grant access however your app resolves admins:
216
+
217
+ ```ruby
218
+ # Devise:
219
+ config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
220
+
221
+ # Basic auth, feature flag, IP allowlist — anything based on the request:
222
+ config.authorize_admin = ->(request) { Flipper.enabled?(:feedback_admin) }
223
+ ```
224
+
225
+ You can also wrap the mount in your own routing constraint (the lambda still
226
+ applies on top):
227
+
228
+ ```ruby
229
+ authenticate :user, ->(user) { user.admin? } do
230
+ mount Ideasbugs::Engine => "/feedback"
231
+ end
232
+ ```
233
+
234
+ ### Screenshots
235
+
236
+ Uploads use Active Storage: `bin/rails active_storage:install` if you haven't
237
+ already. Limits (`max_screenshots`, `max_screenshot_size`) are enforced
238
+ server-side and shown as a hint in the widget. If Active Storage isn't loaded,
239
+ the upload control simply doesn't render and uploads are rejected.
240
+
241
+ ### Notifications
242
+
243
+ `config.on_submit` runs inline after each save — keep it fast or hand off to a
244
+ job:
245
+
246
+ ```ruby
247
+ config.on_submit = ->(feedback) do
248
+ SlackNotifier.post("New #{feedback.kind}: #{feedback.message.truncate(100)}")
249
+ end
250
+ ```
251
+
252
+ ### Localizing the widget
253
+
254
+ Every string resolves through Rails I18n under the `ideasbugs.*` scope
255
+ and follows the current `I18n.locale`. Missing keys fall back to English. To
256
+ add a language or reword the bundled copy, define the keys in your own locale
257
+ files (yours win over the gem's):
258
+
259
+ ```yaml
260
+ # config/locales/nl.yml
261
+ nl:
262
+ ideasbugs:
263
+ button: "Feedback"
264
+ title: "Feedback versturen"
265
+ kinds:
266
+ bug: "Fout melden"
267
+ feature: "Functie aanvragen"
268
+ other: "Overig"
269
+ # …see config/locales/ideasbugs.en.yml for the full key list
270
+ ```
271
+
272
+ Custom kinds get their labels the same way (`ideasbugs.kinds.<kind>`),
273
+ falling back to `kind.humanize`.
274
+
275
+ ### Light / dark / system appearance
276
+
277
+ Both the widget and the dashboard follow the operating-system appearance via
278
+ `prefers-color-scheme` — no configuration needed.
279
+
280
+ ## Working with feedback in code
281
+
282
+ Submissions are ordinary records:
283
+
284
+ ```ruby
285
+ Ideasbugs::Feedback.where(status: "open").newest_first.each do |feedback|
286
+ puts "[#{feedback.kind}] #{feedback.message} — #{feedback.author_label}"
287
+ end
288
+ ```
289
+
290
+ Each row stores `kind`, `section`, `message`, `status` (`open`, `in_review`,
291
+ `resolved`), `page_url`, `user_agent`, and optional `author_id` /
292
+ `author_label`. Screenshots are Active Storage attachments
293
+ (`feedback.screenshots`).
294
+
295
+ ## Security
296
+
297
+ - Submission and dashboard access are both gated **on the server** for every
298
+ request; the dashboard denies everything outside development until you
299
+ configure `authorize_admin`.
300
+ - Screenshots are **streamed through the dashboard's own gate**, never linked
301
+ as public Active Storage blob URLs — a screenshot can contain anything a
302
+ user's screen showed, so it is only reachable by someone who passes
303
+ `authorize_admin`, regardless of how your app configures blob access.
304
+ - The submission endpoint is **rate-limited per IP** (10/minute by default,
305
+ via Rails' built-in limiter on Rails 7.2+), so one user or bot can't flood
306
+ your database with megabytes of uploads.
307
+ - Screenshot count, size, and content type (images only) are validated
308
+ server-side, regardless of what the client claims.
309
+ - The widget code carries the request's Content-Security-Policy nonce (the
310
+ same one `ActionDispatch` emits), so it runs under a nonce-based
311
+ `script-src` policy with no configuration. The runtime config ships as a
312
+ `<script type="application/json">` block (data, not code), so it needs no
313
+ nonce and stays correct across Turbo visits.
314
+ - Author attribution is stored as loose fields (no foreign key into your user
315
+ table), so the gem never couples to your user model.
316
+
317
+ ## Turbo
318
+
319
+ Works with Turbo Drive out of the box. Turbo replaces `<body>` on every visit,
320
+ which would take the floating button with it, so the widget registers its
321
+ document-level listeners once and re-renders on `turbo:load`.
322
+
323
+ ## Development
324
+
325
+ ```bash
326
+ bin/setup # or: bundle install
327
+ bundle exec rake test # unit + integration
328
+ bundle exec rake test:system # browser tests (headless Chrome)
329
+ bundle exec rubocop
330
+ ```
331
+
332
+ Tests run against a dummy Rails app under `test/dummy`; the widget itself is
333
+ covered by Capybara system tests in a real browser. CI runs the suite across
334
+ Rails 7.1 / 7.2 / 8.0 / 8.1 and Ruby 3.2–3.4 (per-version Gemfiles live in
335
+ `gemfiles/`).
336
+
337
+ ## License
338
+
339
+ Released under the [MIT License](MIT-LICENSE).
data/Rakefile ADDED
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/gem_tasks'
4
+ require 'rake/testtask'
5
+
6
+ Rake::TestTask.new(:test) do |t|
7
+ t.libs << 'test'
8
+ t.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 |t|
14
+ t.libs << 'test'
15
+ t.test_files = FileList['test/system/**/*_test.rb']
16
+ end
17
+ end
18
+
19
+ task default: :test
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ class ApplicationController < ActionController::Base
5
+ protect_from_forgery with: :exception
6
+
7
+ private
8
+
9
+ def current_author
10
+ return @current_author if defined?(@current_author)
11
+
12
+ @current_author = Ideasbugs.config.current_user.call(request)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ideasbugs
4
+ class FeedbacksController < ApplicationController
5
+ PER_PAGE = 50
6
+
7
+ layout 'ideasbugs/application', except: :create
8
+
9
+ # The widget posts here; everything else is the triage dashboard.
10
+ before_action :require_enabled, only: :create
11
+ before_action :require_admin, except: :create
12
+ before_action :set_feedback, only: %i[show update destroy]
13
+
14
+ # Throttle the public endpoint per IP so one user or bot can't flood the
15
+ # table (each submission may carry megabytes of screenshots). Uses the
16
+ # rate limiter built into Rails 7.2+ (backed by Rails.cache); on Rails 7.1
17
+ # this is a no-op. Tune or disable via config.rate_limit — read once at
18
+ # boot, after the host's initializer.
19
+ if respond_to?(:rate_limit) && Ideasbugs.config.rate_limit
20
+ rate_limit(**Ideasbugs.config.rate_limit,
21
+ only: :create,
22
+ with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
23
+ end
24
+
25
+ def index
26
+ @status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
27
+ @kind = Ideasbugs.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
28
+ @query = params[:q].to_s.strip.presence
29
+ @counts = Feedback.group(:status).count
30
+
31
+ scope = Feedback.where(status: @status)
32
+ scope = scope.where(kind: @kind) if @kind
33
+ scope = search(scope) if @query
34
+ @page = [params[:page].to_i, 1].max
35
+ @feedbacks = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
36
+ @more = @feedbacks.size > PER_PAGE
37
+ @feedbacks = @feedbacks.first(PER_PAGE)
38
+ end
39
+
40
+ def show; end
41
+
42
+ def create
43
+ feedback = Feedback.new(feedback_params)
44
+ feedback.user_agent = request.user_agent
45
+ attribute_author(feedback)
46
+
47
+ error = attach_screenshots(feedback)
48
+ return render json: { errors: [error] }, status: :unprocessable_entity if error
49
+
50
+ if feedback.save
51
+ notify_host(feedback)
52
+ head :created
53
+ else
54
+ render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
55
+ end
56
+ end
57
+
58
+ def update
59
+ @feedback.update!(params.require(:feedback).permit(:status))
60
+ redirect_back fallback_location: feedback_path(@feedback), status: :see_other
61
+ end
62
+
63
+ def destroy
64
+ @feedback.destroy!
65
+ redirect_to root_path, status: :see_other
66
+ end
67
+
68
+ private
69
+
70
+ def require_enabled
71
+ head :forbidden unless Ideasbugs.enabled?(request)
72
+ end
73
+
74
+ # Server-side gate for the dashboard. Default: development only.
75
+ def require_admin
76
+ return if Ideasbugs.admin?(request)
77
+
78
+ render plain: 'Forbidden. Set Ideasbugs.config.authorize_admin to grant access.',
79
+ status: :forbidden
80
+ end
81
+
82
+ def set_feedback
83
+ @feedback = Feedback.find(params[:id])
84
+ end
85
+
86
+ # Case-insensitive match on the free-text columns. LOWER() keeps it
87
+ # portable across SQLite/PostgreSQL/MySQL, and the explicit ESCAPE makes
88
+ # the sanitized backslash escapes work on SQLite, which has no default
89
+ # LIKE escape character.
90
+ def search(scope)
91
+ pattern = "%#{Feedback.sanitize_sql_like(@query.downcase)}%"
92
+ scope.where(
93
+ "LOWER(message) LIKE :q ESCAPE '\\' OR LOWER(COALESCE(author_label, '')) LIKE :q ESCAPE '\\' " \
94
+ "OR LOWER(COALESCE(section, '')) LIKE :q ESCAPE '\\'",
95
+ q: pattern
96
+ )
97
+ end
98
+
99
+ # The host's hook must never turn a saved submission into a 500 — the
100
+ # feedback is in the database; notification failures are the host's logs'
101
+ # problem.
102
+ def notify_host(feedback)
103
+ Ideasbugs.config.on_submit.call(feedback)
104
+ rescue StandardError => e
105
+ Rails.logger.error("ideasbugs: on_submit hook raised #{e.class}: #{e.message}")
106
+ end
107
+
108
+ def feedback_params
109
+ params.require(:feedback).permit(:kind, :section, :message, :page_url)
110
+ end
111
+
112
+ def attribute_author(feedback)
113
+ author = current_author
114
+ return unless author
115
+
116
+ feedback.author_id = author.id.to_s if author.respond_to?(:id)
117
+ feedback.author_label = Ideasbugs.config.author_label.call(author)
118
+ end
119
+
120
+ # Validates and attaches uploads. Returns an error message, or nil when
121
+ # everything (including "no screenshots at all") is fine.
122
+ def attach_screenshots(feedback)
123
+ files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
124
+ return nil if files.empty?
125
+ return t_error(:error_save) unless Ideasbugs.config.screenshots_enabled?
126
+ return t_error(:error_too_many, count: Ideasbugs.config.max_screenshots) if too_many?(files)
127
+ return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
128
+ return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
129
+
130
+ feedback.screenshots.attach(files)
131
+ nil
132
+ end
133
+
134
+ def too_many?(files)
135
+ files.size > Ideasbugs.config.max_screenshots
136
+ end
137
+
138
+ def max_size
139
+ Ideasbugs.config.max_screenshot_size
140
+ end
141
+
142
+ def max_size_mb
143
+ max_size / (1024 * 1024)
144
+ end
145
+
146
+ def t_error(key, **args)
147
+ defaults = {
148
+ error_save: 'Could not send feedback. Please try again.',
149
+ error_too_many: 'Too many screenshots (max %{count}).',
150
+ error_too_large: 'A screenshot is too large (max %{size} MB).',
151
+ error_rate_limited: 'Too many submissions. Please wait a moment and try again.'
152
+ }
153
+ I18n.t(key, scope: :ideasbugs, default: defaults[key], **args)
154
+ end
155
+ end
156
+ end