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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +43 -0
- data/README.md +18 -3
- data/app/assets/feedback_engine/widget.js +20 -1
- 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: 12457e35c33daf8032e5eae4336964217b969d4d45d8d8a13ca85f6706833e26
|
|
4
|
+
data.tar.gz: 82ce331114ef1a88718e9879f7a263c6ea89456d0fb32c098804431df8fa6106
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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-
|
|
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
|
-
|
|
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.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:
|
|
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.'
|