feedback_engine 0.1.0 → 0.4.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 +43 -0
  3. data/README.md +18 -3
  4. data/app/assets/feedback_engine/widget.js +20 -1
  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: 12457e35c33daf8032e5eae4336964217b969d4d45d8d8a13ca85f6706833e26
4
+ data.tar.gz: 82ce331114ef1a88718e9879f7a263c6ea89456d0fb32c098804431df8fa6106
5
5
  SHA512:
6
- metadata.gz: 3b7eed3a13ee10859d286f34feada2b28490b3a8a658c9ee2ab4fd479c69ab4481333ea16c208494f5ee6cd789656baf18b0612d4656267020849acf37d330b0
7
- data.tar.gz: 18291f5570d23ad44ce193ef0170e30da2b5c594b64af9f04a0ffd764bc6c18026fa3db4c4000d58f2007c1621cc2407b6362391d798a437658b93d8749f415e
6
+ metadata.gz: 16ab901cdc39fbd5dfa1c26ef896717610f8da85a2afd96348bfbaa1b13b196b4d9e12e6fb8d47f2194d0bd86e6ba46729f88e86a3e5089f853ab9dbdfff097c
7
+ data.tar.gz: ee81a5410c06cfaaea26f143ef0ce2bccde945d2d1826e68e30b90f154191e4178cac1f2b2f8279465ccf22b1f20810ca8f9aa2828f6ab184594e7582b56ec2a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 (2026-07-22)
4
+
5
+ - Removed the clipboard-paste / drag-and-drop / file-chips screenshot intake
6
+ added in 0.3.0 — pasted files all arrive as `image.png` and the extra UI
7
+ wasn't worth its complexity. Screenshots are back to a plain file input.
8
+
9
+ ## 0.3.0 (2026-07-22)
10
+
11
+ - Screenshots can be pasted (Cmd/Ctrl+V) or dragged & dropped anywhere on
12
+ the feedback form, not just picked via the file dialog. Selected files show
13
+ as removable chips; non-images are ignored and the configured maximum is
14
+ enforced at intake. (Removed again in 0.4.0.)
15
+ - The dialog traps Tab focus while open and is labelled for screen readers
16
+ (`aria-labelledby`).
17
+ - Dashboard search across message, author, and section (case-insensitive,
18
+ LIKE-wildcard-safe, works on SQLite/PostgreSQL/MySQL).
19
+ - A raising `on_submit` hook no longer turns a saved submission into a 500 —
20
+ the error is logged and the widget still gets its 201.
21
+ - Releases are automated via RubyGems trusted publishing: pushing a `v*` tag
22
+ builds and publishes the gem from CI (one-time setup on rubygems.org
23
+ required; see `.github/workflows/release.yml`).
24
+
25
+ ## 0.2.0 (2026-07-22)
26
+
27
+ Security:
28
+
29
+ - Dashboard screenshots are now streamed through an engine route gated by
30
+ `config.authorize_admin`, instead of linking public Active Storage blob
31
+ URLs. Screenshots can contain anything a user's screen showed; they are no
32
+ longer reachable without passing the dashboard's own authorization,
33
+ regardless of how the host configures blob access. (Also fixes broken
34
+ images in apps that lock their blob endpoints down.)
35
+
36
+ Added:
37
+
38
+ - Per-IP rate limiting on the submission endpoint via Rails' built-in
39
+ limiter (Rails 7.2+; no-op on 7.1). Default 10 submissions/minute; tune or
40
+ disable with `config.rate_limit`. Localized 429 message in all 26 languages.
41
+ - Browser-level test suite for the widget (Capybara + headless Chrome):
42
+ open/submit/validation/screenshot-attach/Escape/custom-trigger flows.
43
+ - CI now tests the full Rails support matrix: 7.1, 7.2, 8.0, and 8.1 across
44
+ Ruby 3.2–3.4.
45
+
3
46
  ## 0.1.0 (2026-07-21)
4
47
 
5
48
  Initial release.
data/README.md CHANGED
@@ -39,7 +39,8 @@ app.
39
39
  and (if you configure it) the current user — stored in the
40
40
  `feedback_engine_feedbacks` table.
41
41
  4. You browse and triage submissions at the mount path (`/feedback`): status
42
- tabs (open → in review → resolved), type filter, screenshots inline.
42
+ tabs (open → in review → resolved), type filter, search, screenshots
43
+ inline.
43
44
 
44
45
  ## Requirements
45
46
 
@@ -117,6 +118,10 @@ FeedbackEngine.configure do |config|
117
118
  config.max_screenshots = 3
118
119
  config.max_screenshot_size = 5.megabytes
119
120
 
121
+ # Per-IP throttle for the submission endpoint (Rails 7.2+; ignored on 7.1).
122
+ # Keyword arguments for Rails' rate limiter; nil disables throttling.
123
+ config.rate_limit = { to: 10, within: 1.minute }
124
+
120
125
  # Show the floating feedback button. Set false and trigger the form from
121
126
  # your own UI instead (see below).
122
127
  config.show_button = true
@@ -234,6 +239,13 @@ Each row stores `kind`, `section`, `message`, `status` (`open`, `in_review`,
234
239
  - Submission and dashboard access are both gated **on the server** for every
235
240
  request; the dashboard denies everything outside development until you
236
241
  configure `authorize_admin`.
242
+ - Screenshots are **streamed through the dashboard's own gate**, never linked
243
+ as public Active Storage blob URLs — a screenshot can contain anything a
244
+ user's screen showed, so it is only reachable by someone who passes
245
+ `authorize_admin`, regardless of how your app configures blob access.
246
+ - The submission endpoint is **rate-limited per IP** (10/minute by default,
247
+ via Rails' built-in limiter on Rails 7.2+), so one user or bot can't flood
248
+ your database with megabytes of uploads.
237
249
  - Screenshot count, size, and content type (images only) are validated
238
250
  server-side, regardless of what the client claims.
239
251
  - The widget code carries the request's Content-Security-Policy nonce (the
@@ -254,11 +266,14 @@ document-level listeners once and re-renders on `turbo:load`.
254
266
 
255
267
  ```bash
256
268
  bin/setup # or: bundle install
257
- bundle exec rspec
269
+ bundle exec rspec # includes browser tests (headless Chrome)
258
270
  bundle exec rubocop
259
271
  ```
260
272
 
261
- Tests run against a dummy Rails app under `spec/dummy`.
273
+ Tests run against a dummy Rails app under `spec/dummy`; the widget itself is
274
+ covered by Capybara system tests in a real browser. CI runs the suite across
275
+ Rails 7.1 / 7.2 / 8.0 / 8.1 and Ruby 3.2–3.4 (per-version Gemfiles live in
276
+ `gemfiles/`).
262
277
 
263
278
  ## License
264
279
 
@@ -101,7 +101,7 @@
101
101
  dialog.id = "fbe-dialog";
102
102
  dialog.setAttribute("role", "dialog");
103
103
  dialog.setAttribute("aria-modal", "true");
104
- dialog.setAttribute("aria-label", config.labels.title);
104
+ dialog.setAttribute("aria-labelledby", "fbe-title");
105
105
  if (config.rtl) dialog.setAttribute("dir", "rtl");
106
106
 
107
107
  dialog.appendChild(header());
@@ -109,6 +109,9 @@
109
109
  overlay.appendChild(dialog);
110
110
  document.body.appendChild(overlay);
111
111
 
112
+ // Keep Tab (and Shift+Tab) cycling inside the dialog while it is open.
113
+ overlay.addEventListener("keydown", trapFocus);
114
+
112
115
  var first = dialog.querySelector("select, textarea, input");
113
116
  if (first) first.focus();
114
117
  }
@@ -120,11 +123,27 @@
120
123
  if (lastFocused && lastFocused.focus) lastFocused.focus();
121
124
  }
122
125
 
126
+ function trapFocus(event) {
127
+ if (event.key !== "Tab" || !overlay) return;
128
+ var focusable = overlay.querySelectorAll("button, select, textarea, input, a[href]");
129
+ if (!focusable.length) return;
130
+ var first = focusable[0];
131
+ var last = focusable[focusable.length - 1];
132
+ if (event.shiftKey && document.activeElement === first) {
133
+ event.preventDefault();
134
+ last.focus();
135
+ } else if (!event.shiftKey && document.activeElement === last) {
136
+ event.preventDefault();
137
+ first.focus();
138
+ }
139
+ }
140
+
123
141
  function header() {
124
142
  var head = document.createElement("div");
125
143
  head.className = "fbe-head";
126
144
 
127
145
  var title = document.createElement("h2");
146
+ title.id = "fbe-title";
128
147
  title.textContent = config.labels.title;
129
148
 
130
149
  var close = document.createElement("button");
@@ -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.4.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.4.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.'