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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +114 -0
- data/MIT-LICENSE +20 -0
- data/README.md +339 -0
- data/Rakefile +19 -0
- data/app/controllers/ideasbugs/application_controller.rb +15 -0
- data/app/controllers/ideasbugs/feedbacks_controller.rb +156 -0
- data/app/controllers/ideasbugs/screenshots_controller.rb +27 -0
- data/app/helpers/ideasbugs/widget_helper.rb +18 -0
- data/app/models/ideasbugs/application_record.rb +7 -0
- data/app/models/ideasbugs/feedback.rb +30 -0
- data/app/views/ideasbugs/feedbacks/index.html.erb +78 -0
- data/app/views/ideasbugs/feedbacks/show.html.erb +63 -0
- data/app/views/layouts/ideasbugs/application.html.erb +76 -0
- data/config/locales/ideasbugs.ar.yml +24 -0
- data/config/locales/ideasbugs.bg.yml +24 -0
- data/config/locales/ideasbugs.bn.yml +24 -0
- data/config/locales/ideasbugs.de.yml +24 -0
- data/config/locales/ideasbugs.el.yml +24 -0
- data/config/locales/ideasbugs.en.yml +47 -0
- data/config/locales/ideasbugs.es.yml +24 -0
- data/config/locales/ideasbugs.fr.yml +24 -0
- data/config/locales/ideasbugs.hi.yml +24 -0
- data/config/locales/ideasbugs.hr.yml +24 -0
- data/config/locales/ideasbugs.id.yml +24 -0
- data/config/locales/ideasbugs.it.yml +24 -0
- data/config/locales/ideasbugs.ja.yml +24 -0
- data/config/locales/ideasbugs.ko.yml +24 -0
- data/config/locales/ideasbugs.lb.yml +24 -0
- data/config/locales/ideasbugs.nl.yml +24 -0
- data/config/locales/ideasbugs.pl.yml +24 -0
- data/config/locales/ideasbugs.pt.yml +24 -0
- data/config/locales/ideasbugs.ro.yml +24 -0
- data/config/locales/ideasbugs.ru.yml +24 -0
- data/config/locales/ideasbugs.th.yml +24 -0
- data/config/locales/ideasbugs.tr.yml +24 -0
- data/config/locales/ideasbugs.uk.yml +24 -0
- data/config/locales/ideasbugs.ur.yml +24 -0
- data/config/locales/ideasbugs.vi.yml +24 -0
- data/config/locales/ideasbugs.zh-CN.yml +24 -0
- data/config/routes.rb +12 -0
- data/lib/generators/ideasbugs/install/install_generator.rb +41 -0
- data/lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt +21 -0
- data/lib/generators/ideasbugs/install/templates/initializer.rb +47 -0
- data/lib/ideasbugs/configuration.rb +93 -0
- data/lib/ideasbugs/engine.rb +13 -0
- data/lib/ideasbugs/version.rb +5 -0
- data/lib/ideasbugs/widget.js +387 -0
- data/lib/ideasbugs/widget.rb +109 -0
- data/lib/ideasbugs.rb +34 -0
- 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
|
+
[](https://rubygems.org/gems/ideasbugs)
|
|
4
|
+
[](https://rubygems.org/gems/ideasbugs)
|
|
5
|
+
[](https://github.com/yshmarov/ideasbugs/actions/workflows/ci.yml)
|
|
6
|
+
[](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
|