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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +25 -7
  4. data/app/assets/feedback_engine/widget.js +106 -7
  5. data/app/controllers/feedback_engine/feedbacks_controller.rb +38 -2
  6. data/app/controllers/feedback_engine/screenshots_controller.rb +27 -0
  7. data/app/views/feedback_engine/feedbacks/index.html.erb +7 -4
  8. data/app/views/feedback_engine/feedbacks/show.html.erb +2 -2
  9. data/config/locales/feedback_engine.ar.yml +1 -0
  10. data/config/locales/feedback_engine.bg.yml +1 -0
  11. data/config/locales/feedback_engine.bn.yml +1 -0
  12. data/config/locales/feedback_engine.de.yml +1 -0
  13. data/config/locales/feedback_engine.el.yml +1 -0
  14. data/config/locales/feedback_engine.en.yml +3 -0
  15. data/config/locales/feedback_engine.es.yml +1 -0
  16. data/config/locales/feedback_engine.fr.yml +1 -0
  17. data/config/locales/feedback_engine.hi.yml +1 -0
  18. data/config/locales/feedback_engine.hr.yml +1 -0
  19. data/config/locales/feedback_engine.id.yml +1 -0
  20. data/config/locales/feedback_engine.it.yml +1 -0
  21. data/config/locales/feedback_engine.ja.yml +1 -0
  22. data/config/locales/feedback_engine.ko.yml +1 -0
  23. data/config/locales/feedback_engine.lb.yml +1 -0
  24. data/config/locales/feedback_engine.nl.yml +1 -0
  25. data/config/locales/feedback_engine.pl.yml +1 -0
  26. data/config/locales/feedback_engine.pt.yml +1 -0
  27. data/config/locales/feedback_engine.ro.yml +1 -0
  28. data/config/locales/feedback_engine.ru.yml +1 -0
  29. data/config/locales/feedback_engine.th.yml +1 -0
  30. data/config/locales/feedback_engine.tr.yml +1 -0
  31. data/config/locales/feedback_engine.uk.yml +1 -0
  32. data/config/locales/feedback_engine.ur.yml +1 -0
  33. data/config/locales/feedback_engine.vi.yml +1 -0
  34. data/config/locales/feedback_engine.zh-CN.yml +1 -0
  35. data/config/routes.rb +5 -1
  36. data/lib/feedback_engine/configuration.rb +6 -0
  37. data/lib/feedback_engine/version.rb +1 -1
  38. data/lib/generators/feedback_engine/install/templates/initializer.rb +4 -0
  39. metadata +3 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df04e8fe3b41eb7019841bb7badfc9b55702401e84c8a43fd53eb979f130c629
4
- data.tar.gz: '00149e51b454535b3d4ffb6441bfc2c20c55475f50d2967b25be7070bed57fd4'
3
+ metadata.gz: 156ebb1c4481df5db12c92701ca07f5199f033aa967f264aa855caf327f4542a
4
+ data.tar.gz: f59ef8d27b7539d16f03eaef38abc4d6ce51ee55cdd630a738433ca7f3463ef7
5
5
  SHA512:
6
- metadata.gz: 3b7eed3a13ee10859d286f34feada2b28490b3a8a658c9ee2ab4fd479c69ab4481333ea16c208494f5ee6cd789656baf18b0612d4656267020849acf37d330b0
7
- data.tar.gz: 18291f5570d23ad44ce193ef0170e30da2b5c594b64af9f04a0ffd764bc6c18026fa3db4c4000d58f2007c1621cc2407b6362391d798a437658b93d8749f415e
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) — limits are configurable and enforced server-side.
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 inline.
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. Limits (`max_screenshots`, `max_screenshot_size`) are enforced
175
- server-side and shown as a hint in the widget. If Active Storage isn't loaded,
176
- the upload control simply doesn't render and uploads are rejected.
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-label", config.labels.title);
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
- var input = document.createElement("input");
227
- input.type = "file";
228
- input.name = "screenshots";
229
- input.multiple = true;
230
- input.accept = "image/*";
231
- var wrap = field(config.labels.screenshots, input);
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
- FeedbackEngine.config.on_submit.call(feedback)
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
- <noscript><button><%= t('feedback_engine.dashboard.filter', default: 'Filter') %></button></noscript>
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 main_app.url_for(screenshot), target: '_blank', rel: 'noopener' do %>
22
- <%= image_tag main_app.url_for(screenshot), alt: screenshot.filename %>
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 @@ it:
21
21
  error_save: "Invio non riuscito. Riprova."
22
22
  error_too_many: "Troppi screenshot (max %{count})."
23
23
  error_too_large: "Uno screenshot è troppo grande (max %{size} MB)."
24
+ error_rate_limited: "Troppi invii. Attendi un momento e riprova."
@@ -21,3 +21,4 @@ ja:
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 @@ ko:
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 @@ 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 @@ th:
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 @@ 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."
@@ -21,3 +21,4 @@ zh-CN:
21
21
  error_save: "发送失败,请重试。"
22
22
  error_too_many: "截图过多(最多 %{count} 张)。"
23
23
  error_too_large: "有一张截图过大(最多 %{size} MB)。"
24
+ error_rate_limited: "提交过于频繁,请稍候再试。"
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'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module FeedbackEngine
4
- VERSION = '0.1.0'
4
+ VERSION = '0.3.0'
5
5
  end
@@ -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.1.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: 4.0.6
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.'