feedback_engine 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +25 -7
- data/app/assets/feedback_engine/widget.js +106 -7
- data/app/controllers/feedback_engine/feedbacks_controller.rb +38 -2
- data/app/controllers/feedback_engine/screenshots_controller.rb +27 -0
- data/app/views/feedback_engine/feedbacks/index.html.erb +7 -4
- data/app/views/feedback_engine/feedbacks/show.html.erb +2 -2
- data/config/locales/feedback_engine.ar.yml +1 -0
- data/config/locales/feedback_engine.bg.yml +1 -0
- data/config/locales/feedback_engine.bn.yml +1 -0
- data/config/locales/feedback_engine.de.yml +1 -0
- data/config/locales/feedback_engine.el.yml +1 -0
- data/config/locales/feedback_engine.en.yml +3 -0
- data/config/locales/feedback_engine.es.yml +1 -0
- data/config/locales/feedback_engine.fr.yml +1 -0
- data/config/locales/feedback_engine.hi.yml +1 -0
- data/config/locales/feedback_engine.hr.yml +1 -0
- data/config/locales/feedback_engine.id.yml +1 -0
- data/config/locales/feedback_engine.it.yml +1 -0
- data/config/locales/feedback_engine.ja.yml +1 -0
- data/config/locales/feedback_engine.ko.yml +1 -0
- data/config/locales/feedback_engine.lb.yml +1 -0
- data/config/locales/feedback_engine.nl.yml +1 -0
- data/config/locales/feedback_engine.pl.yml +1 -0
- data/config/locales/feedback_engine.pt.yml +1 -0
- data/config/locales/feedback_engine.ro.yml +1 -0
- data/config/locales/feedback_engine.ru.yml +1 -0
- data/config/locales/feedback_engine.th.yml +1 -0
- data/config/locales/feedback_engine.tr.yml +1 -0
- data/config/locales/feedback_engine.uk.yml +1 -0
- data/config/locales/feedback_engine.ur.yml +1 -0
- data/config/locales/feedback_engine.vi.yml +1 -0
- data/config/locales/feedback_engine.zh-CN.yml +1 -0
- data/config/routes.rb +5 -1
- data/lib/feedback_engine/configuration.rb +6 -0
- data/lib/feedback_engine/version.rb +1 -1
- data/lib/generators/feedback_engine/install/templates/initializer.rb +4 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 156ebb1c4481df5db12c92701ca07f5199f033aa967f264aa855caf327f4542a
|
|
4
|
+
data.tar.gz: f59ef8d27b7539d16f03eaef38abc4d6ce51ee55cdd630a738433ca7f3463ef7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 827983bd43f6d4b9af39b6d526f2d14755365ce561cfdbb338e240e97a2a54afa2173e0efb645846078d107c7ee234de27356e9814acbdbd891f8d7116e6a30b
|
|
7
|
+
data.tar.gz: 3f311aeb99159a9d538ddd87e8d346d7b2bd7d10b4521fcec6e864945801eba2ccf9a5e58bf2ebd65034189379647dabf50d60bc5e421924cb2b012468953481
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 (2026-07-22)
|
|
4
|
+
|
|
5
|
+
- Screenshots can now be pasted (Cmd/Ctrl+V) or dragged & dropped anywhere on
|
|
6
|
+
the feedback form, not just picked via the file dialog. Selected files show
|
|
7
|
+
as removable chips; non-images are ignored and the configured maximum is
|
|
8
|
+
enforced at intake.
|
|
9
|
+
- The dialog traps Tab focus while open and is labelled for screen readers
|
|
10
|
+
(`aria-labelledby`).
|
|
11
|
+
- Dashboard search across message, author, and section (case-insensitive,
|
|
12
|
+
LIKE-wildcard-safe, works on SQLite/PostgreSQL/MySQL).
|
|
13
|
+
- A raising `on_submit` hook no longer turns a saved submission into a 500 —
|
|
14
|
+
the error is logged and the widget still gets its 201.
|
|
15
|
+
- Releases are automated via RubyGems trusted publishing: pushing a `v*` tag
|
|
16
|
+
builds and publishes the gem from CI (one-time setup on rubygems.org
|
|
17
|
+
required; see `.github/workflows/release.yml`).
|
|
18
|
+
|
|
19
|
+
## 0.2.0 (2026-07-22)
|
|
20
|
+
|
|
21
|
+
Security:
|
|
22
|
+
|
|
23
|
+
- Dashboard screenshots are now streamed through an engine route gated by
|
|
24
|
+
`config.authorize_admin`, instead of linking public Active Storage blob
|
|
25
|
+
URLs. Screenshots can contain anything a user's screen showed; they are no
|
|
26
|
+
longer reachable without passing the dashboard's own authorization,
|
|
27
|
+
regardless of how the host configures blob access. (Also fixes broken
|
|
28
|
+
images in apps that lock their blob endpoints down.)
|
|
29
|
+
|
|
30
|
+
Added:
|
|
31
|
+
|
|
32
|
+
- Per-IP rate limiting on the submission endpoint via Rails' built-in
|
|
33
|
+
limiter (Rails 7.2+; no-op on 7.1). Default 10 submissions/minute; tune or
|
|
34
|
+
disable with `config.rate_limit`. Localized 429 message in all 26 languages.
|
|
35
|
+
- Browser-level test suite for the widget (Capybara + headless Chrome):
|
|
36
|
+
open/submit/validation/screenshot-attach/Escape/custom-trigger flows.
|
|
37
|
+
- CI now tests the full Rails support matrix: 7.1, 7.2, 8.0, and 8.1 across
|
|
38
|
+
Ruby 3.2–3.4.
|
|
39
|
+
|
|
3
40
|
## 0.1.0 (2026-07-21)
|
|
4
41
|
|
|
5
42
|
Initial release.
|
data/README.md
CHANGED
|
@@ -20,7 +20,8 @@ app.
|
|
|
20
20
|
- **Trigger it your way.** Use the built-in floating button, or hide it and
|
|
21
21
|
open the form from any element with a `data-feedback-engine-open` attribute.
|
|
22
22
|
- **Screenshots included.** Users can attach up to 3 images (via Active
|
|
23
|
-
Storage) —
|
|
23
|
+
Storage) — picked, pasted (Cmd/Ctrl+V), or dragged onto the form. Limits are
|
|
24
|
+
configurable and enforced server-side.
|
|
24
25
|
- **Pluggable gating and attribution.** You decide who can send feedback, who
|
|
25
26
|
can read it, and how a submission is attributed to a user.
|
|
26
27
|
- **Localized.** The widget follows your app's `I18n.locale`; translations ship
|
|
@@ -39,7 +40,8 @@ app.
|
|
|
39
40
|
and (if you configure it) the current user — stored in the
|
|
40
41
|
`feedback_engine_feedbacks` table.
|
|
41
42
|
4. You browse and triage submissions at the mount path (`/feedback`): status
|
|
42
|
-
tabs (open → in review → resolved), type filter, screenshots
|
|
43
|
+
tabs (open → in review → resolved), type filter, search, screenshots
|
|
44
|
+
inline.
|
|
43
45
|
|
|
44
46
|
## Requirements
|
|
45
47
|
|
|
@@ -117,6 +119,10 @@ FeedbackEngine.configure do |config|
|
|
|
117
119
|
config.max_screenshots = 3
|
|
118
120
|
config.max_screenshot_size = 5.megabytes
|
|
119
121
|
|
|
122
|
+
# Per-IP throttle for the submission endpoint (Rails 7.2+; ignored on 7.1).
|
|
123
|
+
# Keyword arguments for Rails' rate limiter; nil disables throttling.
|
|
124
|
+
config.rate_limit = { to: 10, within: 1.minute }
|
|
125
|
+
|
|
120
126
|
# Show the floating feedback button. Set false and trigger the form from
|
|
121
127
|
# your own UI instead (see below).
|
|
122
128
|
config.show_button = true
|
|
@@ -171,9 +177,11 @@ end
|
|
|
171
177
|
### Screenshots
|
|
172
178
|
|
|
173
179
|
Uploads use Active Storage: `bin/rails active_storage:install` if you haven't
|
|
174
|
-
already.
|
|
175
|
-
|
|
176
|
-
|
|
180
|
+
already. Users can pick files, paste an image from the clipboard, or drag &
|
|
181
|
+
drop onto the form; selected files show as removable chips. Limits
|
|
182
|
+
(`max_screenshots`, `max_screenshot_size`) are enforced server-side and shown
|
|
183
|
+
as a hint in the widget. If Active Storage isn't loaded, the upload control
|
|
184
|
+
simply doesn't render and uploads are rejected.
|
|
177
185
|
|
|
178
186
|
### Notifications
|
|
179
187
|
|
|
@@ -234,6 +242,13 @@ Each row stores `kind`, `section`, `message`, `status` (`open`, `in_review`,
|
|
|
234
242
|
- Submission and dashboard access are both gated **on the server** for every
|
|
235
243
|
request; the dashboard denies everything outside development until you
|
|
236
244
|
configure `authorize_admin`.
|
|
245
|
+
- Screenshots are **streamed through the dashboard's own gate**, never linked
|
|
246
|
+
as public Active Storage blob URLs — a screenshot can contain anything a
|
|
247
|
+
user's screen showed, so it is only reachable by someone who passes
|
|
248
|
+
`authorize_admin`, regardless of how your app configures blob access.
|
|
249
|
+
- The submission endpoint is **rate-limited per IP** (10/minute by default,
|
|
250
|
+
via Rails' built-in limiter on Rails 7.2+), so one user or bot can't flood
|
|
251
|
+
your database with megabytes of uploads.
|
|
237
252
|
- Screenshot count, size, and content type (images only) are validated
|
|
238
253
|
server-side, regardless of what the client claims.
|
|
239
254
|
- The widget code carries the request's Content-Security-Policy nonce (the
|
|
@@ -254,11 +269,14 @@ document-level listeners once and re-renders on `turbo:load`.
|
|
|
254
269
|
|
|
255
270
|
```bash
|
|
256
271
|
bin/setup # or: bundle install
|
|
257
|
-
bundle exec rspec
|
|
272
|
+
bundle exec rspec # includes browser tests (headless Chrome)
|
|
258
273
|
bundle exec rubocop
|
|
259
274
|
```
|
|
260
275
|
|
|
261
|
-
Tests run against a dummy Rails app under `spec/dummy
|
|
276
|
+
Tests run against a dummy Rails app under `spec/dummy`; the widget itself is
|
|
277
|
+
covered by Capybara system tests in a real browser. CI runs the suite across
|
|
278
|
+
Rails 7.1 / 7.2 / 8.0 / 8.1 and Ruby 3.2–3.4 (per-version Gemfiles live in
|
|
279
|
+
`gemfiles/`).
|
|
262
280
|
|
|
263
281
|
## License
|
|
264
282
|
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
var Z = 2147483000;
|
|
21
21
|
var overlay = null;
|
|
22
22
|
var lastFocused = null;
|
|
23
|
+
var fileInput = null;
|
|
24
|
+
var fileChips = null;
|
|
23
25
|
|
|
24
26
|
function ready(fn) {
|
|
25
27
|
if (document.readyState === "loading") {
|
|
@@ -101,7 +103,7 @@
|
|
|
101
103
|
dialog.id = "fbe-dialog";
|
|
102
104
|
dialog.setAttribute("role", "dialog");
|
|
103
105
|
dialog.setAttribute("aria-modal", "true");
|
|
104
|
-
dialog.setAttribute("aria-
|
|
106
|
+
dialog.setAttribute("aria-labelledby", "fbe-title");
|
|
105
107
|
if (config.rtl) dialog.setAttribute("dir", "rtl");
|
|
106
108
|
|
|
107
109
|
dialog.appendChild(header());
|
|
@@ -109,6 +111,28 @@
|
|
|
109
111
|
overlay.appendChild(dialog);
|
|
110
112
|
document.body.appendChild(overlay);
|
|
111
113
|
|
|
114
|
+
// Keep Tab (and Shift+Tab) cycling inside the dialog while it is open.
|
|
115
|
+
overlay.addEventListener("keydown", trapFocus);
|
|
116
|
+
|
|
117
|
+
// Screenshots can be pasted (Cmd/Ctrl+V) or dropped anywhere on the form —
|
|
118
|
+
// the file picker is just one way in.
|
|
119
|
+
if (config.screenshots.enabled) {
|
|
120
|
+
dialog.addEventListener("paste", function (event) {
|
|
121
|
+
var files = event.clipboardData && event.clipboardData.files;
|
|
122
|
+
if (files && files.length) {
|
|
123
|
+
event.preventDefault();
|
|
124
|
+
addFiles(files);
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
dialog.addEventListener("dragover", function (event) {
|
|
128
|
+
event.preventDefault();
|
|
129
|
+
});
|
|
130
|
+
dialog.addEventListener("drop", function (event) {
|
|
131
|
+
event.preventDefault();
|
|
132
|
+
if (event.dataTransfer) addFiles(event.dataTransfer.files);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
112
136
|
var first = dialog.querySelector("select, textarea, input");
|
|
113
137
|
if (first) first.focus();
|
|
114
138
|
}
|
|
@@ -117,14 +141,32 @@
|
|
|
117
141
|
if (!overlay) return;
|
|
118
142
|
overlay.remove();
|
|
119
143
|
overlay = null;
|
|
144
|
+
fileInput = null;
|
|
145
|
+
fileChips = null;
|
|
120
146
|
if (lastFocused && lastFocused.focus) lastFocused.focus();
|
|
121
147
|
}
|
|
122
148
|
|
|
149
|
+
function trapFocus(event) {
|
|
150
|
+
if (event.key !== "Tab" || !overlay) return;
|
|
151
|
+
var focusable = overlay.querySelectorAll("button, select, textarea, input, a[href]");
|
|
152
|
+
if (!focusable.length) return;
|
|
153
|
+
var first = focusable[0];
|
|
154
|
+
var last = focusable[focusable.length - 1];
|
|
155
|
+
if (event.shiftKey && document.activeElement === first) {
|
|
156
|
+
event.preventDefault();
|
|
157
|
+
last.focus();
|
|
158
|
+
} else if (!event.shiftKey && document.activeElement === last) {
|
|
159
|
+
event.preventDefault();
|
|
160
|
+
first.focus();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
function header() {
|
|
124
165
|
var head = document.createElement("div");
|
|
125
166
|
head.className = "fbe-head";
|
|
126
167
|
|
|
127
168
|
var title = document.createElement("h2");
|
|
169
|
+
title.id = "fbe-title";
|
|
128
170
|
title.textContent = config.labels.title;
|
|
129
171
|
|
|
130
172
|
var close = document.createElement("button");
|
|
@@ -223,19 +265,69 @@
|
|
|
223
265
|
}
|
|
224
266
|
|
|
225
267
|
function screenshotsField() {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
268
|
+
fileInput = document.createElement("input");
|
|
269
|
+
fileInput.type = "file";
|
|
270
|
+
fileInput.name = "screenshots";
|
|
271
|
+
fileInput.multiple = true;
|
|
272
|
+
fileInput.accept = "image/*";
|
|
273
|
+
fileInput.addEventListener("change", renderFileChips);
|
|
274
|
+
var wrap = field(config.labels.screenshots, fileInput);
|
|
232
275
|
var hint = document.createElement("span");
|
|
233
276
|
hint.className = "fbe-hint";
|
|
234
277
|
hint.textContent = config.labels.screenshotsHint;
|
|
235
278
|
wrap.appendChild(hint);
|
|
279
|
+
fileChips = document.createElement("ul");
|
|
280
|
+
fileChips.className = "fbe-chips";
|
|
281
|
+
wrap.appendChild(fileChips);
|
|
236
282
|
return wrap;
|
|
237
283
|
}
|
|
238
284
|
|
|
285
|
+
// Merge pasted/dropped images into the file input (the single source of
|
|
286
|
+
// truth for what gets uploaded), capped at the configured maximum.
|
|
287
|
+
function addFiles(files) {
|
|
288
|
+
if (!fileInput) return;
|
|
289
|
+
var transfer = new DataTransfer();
|
|
290
|
+
var current = Array.prototype.slice.call(fileInput.files);
|
|
291
|
+
var incoming = Array.prototype.slice.call(files).filter(function (file) {
|
|
292
|
+
return /^image\//.test(file.type);
|
|
293
|
+
});
|
|
294
|
+
current.concat(incoming).slice(0, config.screenshots.max).forEach(function (file) {
|
|
295
|
+
transfer.items.add(file);
|
|
296
|
+
});
|
|
297
|
+
fileInput.files = transfer.files;
|
|
298
|
+
renderFileChips();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function removeFile(index) {
|
|
302
|
+
if (!fileInput) return;
|
|
303
|
+
var transfer = new DataTransfer();
|
|
304
|
+
Array.prototype.slice.call(fileInput.files).forEach(function (file, i) {
|
|
305
|
+
if (i !== index) transfer.items.add(file);
|
|
306
|
+
});
|
|
307
|
+
fileInput.files = transfer.files;
|
|
308
|
+
renderFileChips();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function renderFileChips() {
|
|
312
|
+
if (!fileChips) return;
|
|
313
|
+
fileChips.textContent = "";
|
|
314
|
+
Array.prototype.slice.call(fileInput.files).forEach(function (file, index) {
|
|
315
|
+
var chip = document.createElement("li");
|
|
316
|
+
var name = document.createElement("span");
|
|
317
|
+
name.textContent = file.name;
|
|
318
|
+
var remove = document.createElement("button");
|
|
319
|
+
remove.type = "button";
|
|
320
|
+
remove.setAttribute("aria-label", config.labels.close + " " + file.name);
|
|
321
|
+
remove.textContent = "×";
|
|
322
|
+
remove.addEventListener("click", function () {
|
|
323
|
+
removeFile(index);
|
|
324
|
+
});
|
|
325
|
+
chip.appendChild(name);
|
|
326
|
+
chip.appendChild(remove);
|
|
327
|
+
fileChips.appendChild(chip);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
239
331
|
// --- submit -------------------------------------------------------------------
|
|
240
332
|
|
|
241
333
|
function submit(form) {
|
|
@@ -344,6 +436,12 @@
|
|
|
344
436
|
"border:1px solid #d1d5db;border-radius:6px;background:none;color:#1c2024;font:inherit;cursor:pointer}",
|
|
345
437
|
"#fbe-dialog textarea{resize:vertical}",
|
|
346
438
|
"#fbe-dialog .fbe-hint{display:block;margin-top:4px;font-size:12px;color:#6b7280;font-weight:400}",
|
|
439
|
+
"#fbe-dialog .fbe-chips{list-style:none;margin:6px 0 0;padding:0;display:flex;flex-wrap:wrap;gap:6px}",
|
|
440
|
+
"#fbe-dialog .fbe-chips li{display:flex;align-items:center;gap:4px;max-width:100%;",
|
|
441
|
+
"padding:2px 4px 2px 10px;border:1px solid #d1d5db;border-radius:999px;font-size:12px;font-weight:400}",
|
|
442
|
+
"#fbe-dialog .fbe-chips span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:180px}",
|
|
443
|
+
"#fbe-dialog .fbe-chips button{border:0;background:none;color:inherit;cursor:pointer;",
|
|
444
|
+
"font-size:14px;line-height:1;padding:2px 6px}",
|
|
347
445
|
"#fbe-dialog .fbe-error{color:#dc2626;margin:0 0 12px}",
|
|
348
446
|
"#fbe-dialog .fbe-actions{display:flex;justify-content:flex-end;gap:8px}",
|
|
349
447
|
"#fbe-dialog button{padding:8px 14px;border-radius:8px;cursor:pointer;font:inherit}",
|
|
@@ -356,6 +454,7 @@
|
|
|
356
454
|
"#fbe-dialog select,#fbe-dialog textarea,#fbe-dialog input[type=file]{border-color:#2a313a}",
|
|
357
455
|
"#fbe-dialog input[type=file]{color:#9aa2ab}",
|
|
358
456
|
"#fbe-dialog input[type=file]::file-selector-button{border-color:#2a313a;color:#e6e8ea}",
|
|
457
|
+
"#fbe-dialog .fbe-chips li{border-color:#2a313a}",
|
|
359
458
|
"#fbe-dialog .fbe-secondary{border-color:#2a313a}",
|
|
360
459
|
"#fbe-dialog .fbe-hint{color:#9aa2ab}",
|
|
361
460
|
"}"
|
|
@@ -11,13 +11,26 @@ module FeedbackEngine
|
|
|
11
11
|
before_action :require_admin, except: :create
|
|
12
12
|
before_action :set_feedback, only: %i[show update destroy]
|
|
13
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) && FeedbackEngine.config.rate_limit
|
|
20
|
+
rate_limit(**FeedbackEngine.config.rate_limit,
|
|
21
|
+
only: :create,
|
|
22
|
+
with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
|
|
23
|
+
end
|
|
24
|
+
|
|
14
25
|
def index
|
|
15
26
|
@status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
|
|
16
27
|
@kind = FeedbackEngine.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
|
|
28
|
+
@query = params[:q].to_s.strip.presence
|
|
17
29
|
@counts = Feedback.group(:status).count
|
|
18
30
|
|
|
19
31
|
scope = Feedback.where(status: @status)
|
|
20
32
|
scope = scope.where(kind: @kind) if @kind
|
|
33
|
+
scope = search(scope) if @query
|
|
21
34
|
@page = [params[:page].to_i, 1].max
|
|
22
35
|
@feedbacks = scope.newest_first.offset((@page - 1) * PER_PAGE).limit(PER_PAGE + 1).to_a
|
|
23
36
|
@more = @feedbacks.size > PER_PAGE
|
|
@@ -35,7 +48,7 @@ module FeedbackEngine
|
|
|
35
48
|
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
36
49
|
|
|
37
50
|
if feedback.save
|
|
38
|
-
|
|
51
|
+
notify_host(feedback)
|
|
39
52
|
head :created
|
|
40
53
|
else
|
|
41
54
|
render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
|
|
@@ -70,6 +83,28 @@ module FeedbackEngine
|
|
|
70
83
|
@feedback = Feedback.find(params[:id])
|
|
71
84
|
end
|
|
72
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
|
+
FeedbackEngine.config.on_submit.call(feedback)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
Rails.logger.error("feedback_engine: on_submit hook raised #{e.class}: #{e.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
73
108
|
def feedback_params
|
|
74
109
|
params.require(:feedback).permit(:kind, :section, :message, :page_url)
|
|
75
110
|
end
|
|
@@ -112,7 +147,8 @@ module FeedbackEngine
|
|
|
112
147
|
defaults = {
|
|
113
148
|
error_save: 'Could not send feedback. Please try again.',
|
|
114
149
|
error_too_many: 'Too many screenshots (max %{count}).',
|
|
115
|
-
error_too_large: 'A screenshot is too large (max %{size} MB).'
|
|
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.'
|
|
116
152
|
}
|
|
117
153
|
I18n.t(key, scope: :feedback_engine, default: defaults[key], **args)
|
|
118
154
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module FeedbackEngine
|
|
4
|
+
# Serves screenshots to the dashboard under its own authorization, streaming
|
|
5
|
+
# the blob instead of linking Active Storage's public blob URLs. Feedback
|
|
6
|
+
# screenshots can contain anything a user's screen showed, so they must never
|
|
7
|
+
# be reachable without passing the same gate as the dashboard — regardless of
|
|
8
|
+
# how the host app configures (or doesn't configure) blob access.
|
|
9
|
+
class ScreenshotsController < ApplicationController
|
|
10
|
+
before_action :require_admin
|
|
11
|
+
|
|
12
|
+
def show
|
|
13
|
+
screenshot = Feedback.find(params[:feedback_id]).screenshots.find(params[:id])
|
|
14
|
+
|
|
15
|
+
send_data screenshot.download,
|
|
16
|
+
filename: screenshot.filename.to_s,
|
|
17
|
+
type: screenshot.content_type,
|
|
18
|
+
disposition: 'inline'
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def require_admin
|
|
24
|
+
head :forbidden unless FeedbackEngine.admin?(request)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<div class="tabs">
|
|
4
4
|
<% FeedbackEngine::Feedback::STATUSES.each do |status| %>
|
|
5
|
-
<%= link_to feedbacks_path(status: status, kind: @kind),
|
|
5
|
+
<%= link_to feedbacks_path(status: status, kind: @kind, q: @query),
|
|
6
6
|
class: ('active' if status == @status) do %>
|
|
7
7
|
<%= t("feedback_engine.statuses.#{status}", default: status.humanize) %>
|
|
8
8
|
<span class="count"><%= @counts.fetch(status, 0) %></span>
|
|
@@ -20,7 +20,10 @@
|
|
|
20
20
|
</option>
|
|
21
21
|
<% end %>
|
|
22
22
|
</select>
|
|
23
|
-
<
|
|
23
|
+
<input type="search" name="q" value="<%= @query %>"
|
|
24
|
+
placeholder="<%= t('feedback_engine.dashboard.search_placeholder', default: 'Search message, author, section…') %>"
|
|
25
|
+
aria-label="<%= t('feedback_engine.dashboard.search', default: 'Search') %>">
|
|
26
|
+
<button><%= t('feedback_engine.dashboard.search', default: 'Search') %></button>
|
|
24
27
|
</form>
|
|
25
28
|
|
|
26
29
|
<div class="card">
|
|
@@ -66,10 +69,10 @@
|
|
|
66
69
|
<div class="pager">
|
|
67
70
|
<% if @page > 1 %>
|
|
68
71
|
<%= link_to t('feedback_engine.dashboard.newer', default: '← Newer'),
|
|
69
|
-
feedbacks_path(status: @status, kind: @kind, page: @page - 1) %>
|
|
72
|
+
feedbacks_path(status: @status, kind: @kind, q: @query, page: @page - 1) %>
|
|
70
73
|
<% end %>
|
|
71
74
|
<% if @more %>
|
|
72
75
|
<%= link_to t('feedback_engine.dashboard.older', default: 'Older →'),
|
|
73
|
-
feedbacks_path(status: @status, kind: @kind, page: @page + 1) %>
|
|
76
|
+
feedbacks_path(status: @status, kind: @kind, q: @query, page: @page + 1) %>
|
|
74
77
|
<% end %>
|
|
75
78
|
</div>
|
|
@@ -18,8 +18,8 @@
|
|
|
18
18
|
<% if @feedback.screenshots? %>
|
|
19
19
|
<div class="shots">
|
|
20
20
|
<% @feedback.screenshots.each do |screenshot| %>
|
|
21
|
-
<%= link_to
|
|
22
|
-
<%= image_tag
|
|
21
|
+
<%= link_to feedback_screenshot_path(@feedback, screenshot.id), target: '_blank', rel: 'noopener' do %>
|
|
22
|
+
<%= image_tag feedback_screenshot_path(@feedback, screenshot.id), alt: screenshot.filename %>
|
|
23
23
|
<% end %>
|
|
24
24
|
<% end %>
|
|
25
25
|
</div>
|
|
@@ -21,3 +21,4 @@ ar:
|
|
|
21
21
|
error_save: "تعذر إرسال الملاحظات. يرجى المحاولة مرة أخرى."
|
|
22
22
|
error_too_many: "عدد كبير جدًا من لقطات الشاشة (الحد الأقصى %{count})."
|
|
23
23
|
error_too_large: "إحدى لقطات الشاشة كبيرة جدًا (الحد الأقصى %{size} ميغابايت)."
|
|
24
|
+
error_rate_limited: "عدد كبير جدًا من الإرسالات. يرجى الانتظار قليلًا والمحاولة مرة أخرى."
|
|
@@ -21,3 +21,4 @@ bg:
|
|
|
21
21
|
error_save: "Изпращането не бе успешно. Моля, опитайте отново."
|
|
22
22
|
error_too_many: "Твърде много екранни снимки (макс. %{count})."
|
|
23
23
|
error_too_large: "Екранна снимка е твърде голяма (макс. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Твърде много изпращания. Изчакайте малко и опитайте отново."
|
|
@@ -21,3 +21,4 @@ bn:
|
|
|
21
21
|
error_save: "মতামত পাঠানো যায়নি। আবার চেষ্টা করুন।"
|
|
22
22
|
error_too_many: "অতিরিক্ত স্ক্রিনশট (সর্বোচ্চ %{count}টি)।"
|
|
23
23
|
error_too_large: "একটি স্ক্রিনশট খুব বড় (সর্বোচ্চ %{size} MB)।"
|
|
24
|
+
error_rate_limited: "অনেক বেশি জমা হয়েছে। একটু অপেক্ষা করে আবার চেষ্টা করুন।"
|
|
@@ -21,3 +21,4 @@ de:
|
|
|
21
21
|
error_save: "Senden fehlgeschlagen. Bitte erneut versuchen."
|
|
22
22
|
error_too_many: "Zu viele Screenshots (max. %{count})."
|
|
23
23
|
error_too_large: "Ein Screenshot ist zu groß (max. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Zu viele Einsendungen. Bitte warten Sie einen Moment und versuchen Sie es erneut."
|
|
@@ -21,3 +21,4 @@ el:
|
|
|
21
21
|
error_save: "Η αποστολή απέτυχε. Δοκιμάστε ξανά."
|
|
22
22
|
error_too_many: "Πάρα πολλά στιγμιότυπα οθόνης (έως %{count})."
|
|
23
23
|
error_too_large: "Ένα στιγμιότυπο οθόνης είναι πολύ μεγάλο (έως %{size} MB)."
|
|
24
|
+
error_rate_limited: "Πάρα πολλές υποβολές. Περιμένετε λίγο και δοκιμάστε ξανά."
|
|
@@ -21,6 +21,7 @@ en:
|
|
|
21
21
|
error_save: "Could not send feedback. Please try again."
|
|
22
22
|
error_too_many: "Too many screenshots (max %{count})."
|
|
23
23
|
error_too_large: "A screenshot is too large (max %{size} MB)."
|
|
24
|
+
error_rate_limited: "Too many submissions. Please wait a moment and try again."
|
|
24
25
|
statuses:
|
|
25
26
|
open: "Open"
|
|
26
27
|
in_review: "In review"
|
|
@@ -29,6 +30,8 @@ en:
|
|
|
29
30
|
title: "Feedback"
|
|
30
31
|
all_kinds: "All types"
|
|
31
32
|
filter: "Filter"
|
|
33
|
+
search: "Search"
|
|
34
|
+
search_placeholder: "Search message, author, section…"
|
|
32
35
|
from: "From"
|
|
33
36
|
filed: "Filed"
|
|
34
37
|
page: "Page"
|
|
@@ -21,3 +21,4 @@ es:
|
|
|
21
21
|
error_save: "No se pudo enviar. Inténtalo de nuevo."
|
|
22
22
|
error_too_many: "Demasiadas capturas (máx. %{count})."
|
|
23
23
|
error_too_large: "Una captura es demasiado grande (máx. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Demasiados envíos. Espera un momento e inténtalo de nuevo."
|
|
@@ -21,3 +21,4 @@ fr:
|
|
|
21
21
|
error_save: "Envoi impossible. Veuillez réessayer."
|
|
22
22
|
error_too_many: "Trop de captures d'écran (max %{count})."
|
|
23
23
|
error_too_large: "Une capture est trop volumineuse (max %{size} Mo)."
|
|
24
|
+
error_rate_limited: "Trop d'envois. Veuillez patienter un instant et réessayer."
|
|
@@ -21,3 +21,4 @@ hi:
|
|
|
21
21
|
error_save: "प्रतिक्रिया नहीं भेजी जा सकी। कृपया फिर से प्रयास करें।"
|
|
22
22
|
error_too_many: "बहुत अधिक स्क्रीनशॉट (अधिकतम %{count})।"
|
|
23
23
|
error_too_large: "एक स्क्रीनशॉट बहुत बड़ा है (अधिकतम %{size} MB)।"
|
|
24
|
+
error_rate_limited: "बहुत अधिक सबमिशन। कृपया थोड़ी देर रुककर फिर से प्रयास करें।"
|
|
@@ -21,3 +21,4 @@ hr:
|
|
|
21
21
|
error_save: "Slanje nije uspjelo. Pokušajte ponovno."
|
|
22
22
|
error_too_many: "Previše snimki zaslona (najviše %{count})."
|
|
23
23
|
error_too_large: "Snimka zaslona je prevelika (najviše %{size} MB)."
|
|
24
|
+
error_rate_limited: "Previše slanja. Pričekajte trenutak i pokušajte ponovno."
|
|
@@ -21,3 +21,4 @@ id:
|
|
|
21
21
|
error_save: "Masukan gagal terkirim. Silakan coba lagi."
|
|
22
22
|
error_too_many: "Terlalu banyak tangkapan layar (maks. %{count})."
|
|
23
23
|
error_too_large: "Sebuah tangkapan layar terlalu besar (maks. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Terlalu banyak kiriman. Tunggu sebentar dan coba lagi."
|
|
@@ -21,3 +21,4 @@ lb:
|
|
|
21
21
|
error_save: "Konnt net geschéckt ginn. Probéiert w.e.g. nach eng Kéier."
|
|
22
22
|
error_too_many: "Ze vill Screenshots (max. %{count})."
|
|
23
23
|
error_too_large: "E Screenshot ass ze grouss (max. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Ze vill Envoien. Waart w.e.g. e Moment a probéiert nach eng Kéier."
|
|
@@ -21,3 +21,4 @@ nl:
|
|
|
21
21
|
error_save: "Versturen mislukt. Probeer het opnieuw."
|
|
22
22
|
error_too_many: "Te veel schermafbeeldingen (max. %{count})."
|
|
23
23
|
error_too_large: "Een schermafbeelding is te groot (max. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Te veel inzendingen. Wacht even en probeer het opnieuw."
|
|
@@ -21,3 +21,4 @@ pl:
|
|
|
21
21
|
error_save: "Nie udało się wysłać opinii. Spróbuj ponownie."
|
|
22
22
|
error_too_many: "Za dużo zrzutów ekranu (maks. %{count})."
|
|
23
23
|
error_too_large: "Zrzut ekranu jest za duży (maks. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Zbyt wiele zgłoszeń. Odczekaj chwilę i spróbuj ponownie."
|
|
@@ -21,3 +21,4 @@ pt:
|
|
|
21
21
|
error_save: "Não foi possível enviar. Tente novamente."
|
|
22
22
|
error_too_many: "Muitas capturas de tela (máx. %{count})."
|
|
23
23
|
error_too_large: "Uma captura é grande demais (máx. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Envios demais. Aguarde um momento e tente novamente."
|
|
@@ -21,3 +21,4 @@ ro:
|
|
|
21
21
|
error_save: "Trimiterea a eșuat. Încearcă din nou."
|
|
22
22
|
error_too_many: "Prea multe capturi de ecran (max. %{count})."
|
|
23
23
|
error_too_large: "O captură de ecran este prea mare (max. %{size} MB)."
|
|
24
|
+
error_rate_limited: "Prea multe trimiteri. Așteaptă un moment și încearcă din nou."
|
|
@@ -21,3 +21,4 @@ ru:
|
|
|
21
21
|
error_save: "Не удалось отправить. Попробуйте ещё раз."
|
|
22
22
|
error_too_many: "Слишком много скриншотов (макс. %{count})."
|
|
23
23
|
error_too_large: "Скриншот слишком большой (макс. %{size} МБ)."
|
|
24
|
+
error_rate_limited: "Слишком много отправок. Подождите немного и попробуйте ещё раз."
|
|
@@ -21,3 +21,4 @@ tr:
|
|
|
21
21
|
error_save: "Gönderilemedi. Lütfen tekrar deneyin."
|
|
22
22
|
error_too_many: "Çok fazla ekran görüntüsü (en fazla %{count})."
|
|
23
23
|
error_too_large: "Bir ekran görüntüsü çok büyük (en fazla %{size} MB)."
|
|
24
|
+
error_rate_limited: "Çok fazla gönderim. Lütfen biraz bekleyip tekrar deneyin."
|
|
@@ -21,3 +21,4 @@ uk:
|
|
|
21
21
|
error_save: "Не вдалося надіслати. Спробуйте ще раз."
|
|
22
22
|
error_too_many: "Забагато знімків екрана (макс. %{count})."
|
|
23
23
|
error_too_large: "Знімок екрана завеликий (макс. %{size} МБ)."
|
|
24
|
+
error_rate_limited: "Забагато надсилань. Зачекайте хвилинку та спробуйте ще раз."
|
|
@@ -21,3 +21,4 @@ ur:
|
|
|
21
21
|
error_save: "رائے نہیں بھیجی جا سکی۔ دوبارہ کوشش کریں۔"
|
|
22
22
|
error_too_many: "بہت زیادہ اسکرین شاٹس (زیادہ سے زیادہ %{count})۔"
|
|
23
23
|
error_too_large: "ایک اسکرین شاٹ بہت بڑا ہے (زیادہ سے زیادہ %{size} MB)۔"
|
|
24
|
+
error_rate_limited: "بہت زیادہ ارسالات۔ براہ کرم تھوڑا انتظار کریں اور دوبارہ کوشش کریں۔"
|
|
@@ -21,3 +21,4 @@ vi:
|
|
|
21
21
|
error_save: "Không thể gửi phản hồi. Vui lòng thử lại."
|
|
22
22
|
error_too_many: "Quá nhiều ảnh chụp màn hình (tối đa %{count})."
|
|
23
23
|
error_too_large: "Một ảnh chụp màn hình quá lớn (tối đa %{size} MB)."
|
|
24
|
+
error_rate_limited: "Gửi quá nhiều lần. Vui lòng đợi một lát rồi thử lại."
|
data/config/routes.rb
CHANGED
|
@@ -2,7 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
FeedbackEngine::Engine.routes.draw do
|
|
4
4
|
# create is the public widget endpoint; the rest is the triage dashboard.
|
|
5
|
-
resources :feedbacks, only: %i[create index show update destroy]
|
|
5
|
+
resources :feedbacks, only: %i[create index show update destroy] do
|
|
6
|
+
# Screenshots stream through the dashboard's own gate, never via public
|
|
7
|
+
# Active Storage blob URLs.
|
|
8
|
+
resources :screenshots, only: :show
|
|
9
|
+
end
|
|
6
10
|
|
|
7
11
|
root to: 'feedbacks#index'
|
|
8
12
|
end
|
|
@@ -40,6 +40,11 @@ module FeedbackEngine
|
|
|
40
40
|
# Upload limits, enforced server-side and mirrored in the widget.
|
|
41
41
|
attr_accessor :max_screenshots, :max_screenshot_size
|
|
42
42
|
|
|
43
|
+
# Per-IP throttle for the public submission endpoint, as keyword arguments
|
|
44
|
+
# for Rails' rate limiter (Rails 7.2+; ignored on 7.1). Read once when the
|
|
45
|
+
# controller loads — set it in an initializer. nil disables throttling.
|
|
46
|
+
attr_accessor :rate_limit
|
|
47
|
+
|
|
43
48
|
# Show the floating feedback button. Set false to trigger the widget from
|
|
44
49
|
# your own UI instead: any element with a `data-feedback-engine-open`
|
|
45
50
|
# attribute opens the form.
|
|
@@ -68,6 +73,7 @@ module FeedbackEngine
|
|
|
68
73
|
@screenshots = true
|
|
69
74
|
@max_screenshots = 3
|
|
70
75
|
@max_screenshot_size = 5 * 1024 * 1024
|
|
76
|
+
@rate_limit = { to: 10, within: 60 }
|
|
71
77
|
@show_button = true
|
|
72
78
|
@button_label = nil
|
|
73
79
|
@mount_path = '/feedback'
|
|
@@ -28,6 +28,10 @@ FeedbackEngine.configure do |config|
|
|
|
28
28
|
# config.max_screenshots = 3
|
|
29
29
|
# config.max_screenshot_size = 5.megabytes
|
|
30
30
|
|
|
31
|
+
# Per-IP throttle for the submission endpoint (Rails 7.2+; ignored on 7.1).
|
|
32
|
+
# Keyword arguments for Rails' rate limiter; nil disables throttling.
|
|
33
|
+
# config.rate_limit = { to: 10, within: 1.minute }
|
|
34
|
+
|
|
31
35
|
# Show the floating feedback button. Set false and add
|
|
32
36
|
# `data-feedback-engine-open` to any element to trigger the form yourself.
|
|
33
37
|
# config.show_button = true
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: feedback_engine
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Yaroslav Shmarov
|
|
@@ -41,6 +41,7 @@ files:
|
|
|
41
41
|
- app/assets/feedback_engine/widget.js
|
|
42
42
|
- app/controllers/feedback_engine/application_controller.rb
|
|
43
43
|
- app/controllers/feedback_engine/feedbacks_controller.rb
|
|
44
|
+
- app/controllers/feedback_engine/screenshots_controller.rb
|
|
44
45
|
- app/helpers/feedback_engine/widget_helper.rb
|
|
45
46
|
- app/models/feedback_engine/application_record.rb
|
|
46
47
|
- app/models/feedback_engine/feedback.rb
|
|
@@ -103,7 +104,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
103
104
|
- !ruby/object:Gem::Version
|
|
104
105
|
version: '0'
|
|
105
106
|
requirements: []
|
|
106
|
-
rubygems_version:
|
|
107
|
+
rubygems_version: 3.6.9
|
|
107
108
|
specification_version: 4
|
|
108
109
|
summary: 'In-app product feedback collection for Rails: a drop-in widget and a built-in
|
|
109
110
|
triage dashboard.'
|