ideasbugs 0.7.7 → 0.8.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/AGENTS.md +174 -0
- data/CHANGELOG.md +42 -0
- data/README.md +7 -0
- data/app/controllers/concerns/ideasbugs/request_context.rb +51 -0
- data/app/controllers/ideasbugs/application_controller.rb +9 -21
- data/app/controllers/ideasbugs/dashboard_controller.rb +30 -0
- data/app/controllers/ideasbugs/feedbacks_controller.rb +1 -108
- data/app/controllers/ideasbugs/screenshots_controller.rb +1 -3
- data/app/controllers/ideasbugs/submissions_controller.rb +100 -0
- data/app/views/ideasbugs/feedbacks/index.html.erb +87 -85
- data/app/views/ideasbugs/feedbacks/show.html.erb +7 -5
- data/app/views/ideasbugs/shared/_dashboard.html.erb +13 -0
- data/app/views/layouts/ideasbugs/application.html.erb +8 -5
- data/config/routes.rb +6 -2
- data/lib/generators/ideasbugs/install/install_generator.rb +2 -6
- data/lib/generators/ideasbugs/install/templates/create_ideasbugs_feedbacks.rb.tt +1 -1
- data/lib/generators/ideasbugs/install/templates/initializer.rb +8 -0
- data/lib/generators/ideasbugs/migration_helpers.rb +33 -0
- data/lib/generators/ideasbugs/tenant/tenant_generator.rb +2 -6
- data/lib/ideasbugs/configuration.rb +21 -1
- data/lib/ideasbugs/dashboard.css +160 -139
- data/lib/ideasbugs/version.rb +1 -1
- data/lib/ideasbugs.rb +7 -0
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 19b7014d62b42ad3b3a763edf8907a7e1e0d2a67ab7b5ae1001d1f3440cc0d67
|
|
4
|
+
data.tar.gz: 0f327125389c6ccc2d57f180b09d24607f383d03ac1d70ad13c9c250d3670842
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f1f27d97cda7c058c8d12c4498e30a57a9bc3899ce61aca61cb4cb5b542c4359b48e69d7a00c8867ddddc421cb561fb843039d6ac36bce4b164d9c140229e421
|
|
7
|
+
data.tar.gz: 9bca34a90ac4d1e4f15ddfb8263a0e8690aa61cb669f1519e83c43481e5e64c05133ec5317c4432b02484af66fdf2123f4574fbdb9710930bd1c0cdb94d3ed0f
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# AGENTS.md
|
|
2
|
+
|
|
3
|
+
Instructions for coding agents. Two audiences:
|
|
4
|
+
|
|
5
|
+
- **[Installing ideasbugs into a Rails app](#installing-into-a-rails-app)** — you are working in a host app and were asked to add product feedback, bug reports, or a feature-request board.
|
|
6
|
+
- **[Working on the gem itself](#working-on-the-gem-itself)** — you are working in this repository.
|
|
7
|
+
|
|
8
|
+
Requirements: Ruby >= 3.2, Rails >= 7.1. Active Storage only for screenshots. The widget needs the CSRF token from `csrf_meta_tags`, which a standard Rails layout already has.
|
|
9
|
+
|
|
10
|
+
If you are in a host app and this file is not in front of you, it ships inside the gem: `cat "$(bundle show ideasbugs)/AGENTS.md"`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Installing into a Rails app
|
|
15
|
+
|
|
16
|
+
### 1. Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
bundle add ideasbugs
|
|
20
|
+
bin/rails generate ideasbugs:install
|
|
21
|
+
bin/rails db:migrate
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The generator writes `config/initializers/ideasbugs.rb`, one migration (`ideasbugs_feedbacks`), and `mount_ideasbugs at: "/feedback"` into `config/routes.rb`. Note the mount path is **`/feedback`**, not `/ideasbugs`. Read the initializer it wrote — every option is documented there in comments, and it is the source of truth over any summary of it, including this file.
|
|
25
|
+
|
|
26
|
+
Every `config.…` line below belongs inside the `Ideasbugs.configure do |config|` block in that initializer. Uncomment and edit in place rather than appending a second `configure` block.
|
|
27
|
+
|
|
28
|
+
### 2. Wire the three things the generator cannot
|
|
29
|
+
|
|
30
|
+
**a. The widget tag.** Nothing appears until this is on the page:
|
|
31
|
+
|
|
32
|
+
```erb
|
|
33
|
+
<%# app/views/layouts/application.html.erb, before </body> %>
|
|
34
|
+
<%= ideasbugs_tag %>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The helper is injected into ActionView by the engine — no include, no import, no asset pipeline entry. A floating **Feedback** button appears bottom-right.
|
|
38
|
+
|
|
39
|
+
**b. `authorize_admin` — do this before deploying.** The dashboard at `/feedback` defaults to **development only**. It fails closed, so shipping without this is not an open dashboard — it is a 403 reading "Forbidden. Set Ideasbugs.config.authorize_admin to grant access."
|
|
40
|
+
|
|
41
|
+
Note the asymmetry, and that it is deliberate: **`enabled` defaults to everyone** (real users in production are the point of feedback collection) while **`authorize_admin` defaults to nobody outside development**.
|
|
42
|
+
|
|
43
|
+
```ruby
|
|
44
|
+
config.authorize_admin = ->(request) { request.env["warden"]&.user&.admin? }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**c. Attribution**, if the app has users. Optional, but without it every submission is anonymous.
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
config.current_user = ->(request) { request.env["warden"]&.user }
|
|
51
|
+
config.author_label = ->(user) { user.email } # the short label stored + shown
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
> **`enabled`, `authorize_admin`, `current_user` and `tenant` receive the raw `request`, not a controller.** Writing `->(request) { current_user }` is the most common mistake here — that method does not exist in this scope. Resolve the user *from the request*: Warden env, a signed cookie, `Current.user` if middleware already set it. `author_label` is the exception: it receives whatever `current_user` returned.
|
|
55
|
+
|
|
56
|
+
Rails 8 built-in auth:
|
|
57
|
+
|
|
58
|
+
```ruby
|
|
59
|
+
config.current_user = lambda do |request|
|
|
60
|
+
token = request.cookies["session_token"]
|
|
61
|
+
Session.find_signed(token)&.user if token
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
### 3. Verify
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
bin/rails routes | grep ideasbugs # engine mounted
|
|
69
|
+
bin/rails ideasbugs:seed_demo # optional sample feedback, idempotent
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then in the running app: load any page, confirm the Feedback button appears, send one, and triage it at `/feedback`.
|
|
73
|
+
|
|
74
|
+
### Shaping the widget
|
|
75
|
+
|
|
76
|
+
```ruby
|
|
77
|
+
config.kinds = %w[bug feature other] # labels resolve through I18n (ideasbugs.kinds.<kind>)
|
|
78
|
+
config.sections = ["Billing", "Dashboard"] # [] hides the select entirely
|
|
79
|
+
config.show_button = false # then open it from your own UI
|
|
80
|
+
config.button_label = "Report a problem" # nil = localized default
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
With `show_button = false`, any element carrying `data-ideasbugs-open` opens the form — put it in a menu, a footer, a help panel.
|
|
84
|
+
|
|
85
|
+
### Screenshots
|
|
86
|
+
|
|
87
|
+
`config.screenshots` is on by default but **requires Active Storage in the host app** (`rails active_storage:install`); the widget hides the upload control when it is off or Active Storage is absent, and the config exposes `screenshots_enabled?` for exactly that pair of conditions. Caps: `max_screenshots` (3), `max_screenshot_size` (5 MB), both enforced server-side. Images stream through the dashboard's own gate at `/feedback/feedbacks/:id/screenshots/:id` — **never a public blob URL**. Do not build your own blob links.
|
|
88
|
+
|
|
89
|
+
### Statuses
|
|
90
|
+
|
|
91
|
+
`open → in_review → resolved`, as plain strings in `Ideasbugs::Feedback::STATUSES` with a scope per status (`Feedback.open`, `.in_review`, `.resolved`) plus `newest_first`. Deliberately not an Active Record enum — `open` as an enum scope would collide with `Kernel#open`. Do not "modernize" it into an enum.
|
|
92
|
+
|
|
93
|
+
### Multi-tenancy
|
|
94
|
+
|
|
95
|
+
One resolver returning an **opaque key** — GlobalID, id, subdomain, slug. The gem never takes a foreign key into host models:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
config.tenant = ->(request) { Current.customer&.to_gid&.to_s }
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Optional sugar on a host model (`has_feedback` is available on every Active Record class already):
|
|
102
|
+
|
|
103
|
+
```ruby
|
|
104
|
+
class Customer < ApplicationRecord
|
|
105
|
+
has_feedback # keyed by to_gid.to_s — must match config.tenant
|
|
106
|
+
end
|
|
107
|
+
customer.feedback.open
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`bin/rails generate ideasbugs:tenant` exists **only** to add the `tenant` column to installs made before it existed. A fresh install already has it, and running that generator will fail on a duplicate column. Do not run it as part of a new install.
|
|
111
|
+
|
|
112
|
+
### Do not
|
|
113
|
+
|
|
114
|
+
- **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `ideasbugs_tag` renders what is needed and the engine serves the code same-origin. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
|
|
115
|
+
- **Do not build your own dashboard.** Use the mounted one; `config.admin_layout = "admin/application"` renders it inside an existing admin shell.
|
|
116
|
+
- **Do not expose screenshots by blob URL** — the gated route exists so a leaked signed URL cannot hand over a customer's screenshot.
|
|
117
|
+
- **Do not set config outside the initializer.** `rate_limit` in particular is read once when the controller class loads; assigning config per-request mutates it process-wide.
|
|
118
|
+
- **Do not convert the status strings to an enum** (see above).
|
|
119
|
+
|
|
120
|
+
### Configuration worth knowing
|
|
121
|
+
|
|
122
|
+
Everything is optional; a fresh install works with zero config. Full list with comments is in the generated initializer.
|
|
123
|
+
|
|
124
|
+
| Option | Default | Note |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| `authorize_admin` | development only | **Who can read the dashboard. Set before deploying.** |
|
|
127
|
+
| `enabled` | everyone | Per-request gate for the widget and submissions |
|
|
128
|
+
| `current_user` | `nil` | Receives the request |
|
|
129
|
+
| `author_label` | email, else `to_s` | Receives the user |
|
|
130
|
+
| `tenant` | `nil` | One board per tenant — see [Multi-tenancy](#multi-tenancy) |
|
|
131
|
+
| `kinds` | `bug feature other` | Labels via `ideasbugs.kinds.<kind>` |
|
|
132
|
+
| `sections` | `[]` | App areas as a select; empty hides it |
|
|
133
|
+
| `screenshots` | `true` | Needs Active Storage; inert without it |
|
|
134
|
+
| `max_screenshots`, `max_screenshot_size` | `3`, `5.megabytes` | Enforced server-side |
|
|
135
|
+
| `storage_service` | app default | A `storage.yml` key for a dedicated bucket |
|
|
136
|
+
| `show_button`, `button_label` | `true`, localized | `false` = open from `data-ideasbugs-open` |
|
|
137
|
+
| `admin_layout` | `ideasbugs/application` | Render inside your admin shell |
|
|
138
|
+
| `rate_limit` | `{ to: 10, within: 60 }` | Rails 7.2+; ignored on 7.1. `nil` disables |
|
|
139
|
+
| `mount_path` | `"/feedback"` | Keep in sync with `mount_ideasbugs at:` |
|
|
140
|
+
| `on_submit` | no-op | Runs inline after save — Slack, email, a ticket |
|
|
141
|
+
|
|
142
|
+
### Common failure modes
|
|
143
|
+
|
|
144
|
+
| Symptom | Cause |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `/feedback` returns 403 "Set Ideasbugs.config.authorize_admin to grant access" | Exactly what it says: still at the development-only default |
|
|
147
|
+
| No Feedback button | `ideasbugs_tag` missing from the rendered layout, `config.enabled` false, or `show_button = false` with no opener of your own |
|
|
148
|
+
| Submissions rejected with an invalid-token error | The layout is missing `csrf_meta_tags` |
|
|
149
|
+
| No screenshot upload control | Active Storage not installed, or `screenshots = false` |
|
|
150
|
+
| `ideasbugs:tenant` fails on a duplicate column | It is an upgrade generator for pre-tenant installs; a fresh install already has the column |
|
|
151
|
+
| `undefined local variable current_user` in the initializer | A gate lambda treated its argument as a controller. It is a `request` |
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Working on the gem itself
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
bundle exec rake test # minitest, dummy app under test/dummy
|
|
159
|
+
bundle exec rake test:system # browser tests, separate task
|
|
160
|
+
bundle exec rubocop # must be clean
|
|
161
|
+
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test # 7.1, 7.2, 8.0, 8.1 in gemfiles/
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Layout: `app/` controller, model, dashboard views · `lib/ideasbugs/` config, widget JS, seeds, engine, `has_feedback` · `lib/generators/ideasbugs/` install and tenant · `config/locales/` · `test/` minitest with `test/dummy` as the host app, system tests excluded from the default task.
|
|
165
|
+
|
|
166
|
+
Conventions this codebase holds to — follow them rather than the first thing that works:
|
|
167
|
+
|
|
168
|
+
- **Multi-tenancy is an opaque string key, never a foreign key.** `config.tenant` returns whatever the host wants; `has_feedback` is a veneer over `Feedback.for_tenant`. No association, no `owner_type` coupling.
|
|
169
|
+
- **Active Storage is optional at runtime.** `screenshots_enabled?` checks the switch *and* whether the constant is defined, so an app without Active Storage gets a working widget rather than an exception.
|
|
170
|
+
- **Attachments stream through the engine's gate**, never a public blob URL.
|
|
171
|
+
- **The widget is plain JS served same-origin by the engine** — no build step, no framework.
|
|
172
|
+
- **The dummy app pins `config.active_job.queue_adapter = :test`.** Do not remove it or let it drift back to the `:async` default. Attaching a screenshot enqueues Active Storage's analysis job, and `:async` runs it on a background thread that checks out its own connection — writes no test transaction covers, landing in the middle of whatever runs next. That is a suite that fails order-dependently in a test which never created a row, and it is miserable to trace back.
|
|
173
|
+
- Every user-facing change bumps `lib/ideasbugs/version.rb` and adds a `CHANGELOG.md` entry that says what it costs, not only what it adds.
|
|
174
|
+
- Commit messages are prose that explains the tradeoff — read `git log` before writing one.
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.8.0
|
|
4
|
+
|
|
5
|
+
- **`config.admin_layout` now works on its own.** The dashboard's stylesheet was
|
|
6
|
+
declared in the gem's layout, so replacing that layout dropped it and the
|
|
7
|
+
dashboard rendered unstyled. It moves into the views, so every layout gets it
|
|
8
|
+
with nothing asked of the host.
|
|
9
|
+
- **The dashboard stylesheet no longer claims selectors it does not own.** It
|
|
10
|
+
styled bare `*`, `body` and `a`, and its `.container`, `.card` and `.tabs` are
|
|
11
|
+
names other frameworks use too. Component rules now nest inside an
|
|
12
|
+
`.ib-dashboard` wrapper the views render, and every custom property is `--ib-`
|
|
13
|
+
prefixed — that collision ran both ways, so a host defining `--bg` recoloured
|
|
14
|
+
the dashboard just as easily.
|
|
15
|
+
- **Added `config.base_controller_class`.** Name the controller your own admin
|
|
16
|
+
inherits from and the dashboard adopts its layout, helpers, authentication and
|
|
17
|
+
request context. Default is unchanged.
|
|
18
|
+
- **`create` moved to `Ideasbugs::SubmissionsController`.** One controller served
|
|
19
|
+
both the widget's write endpoint and the triage actions, so
|
|
20
|
+
`base_controller_class` would have put staff authentication in front of every
|
|
21
|
+
bug report. `POST /feedbacks` now routes there; the URL is unchanged. If you
|
|
22
|
+
referenced `Ideasbugs::FeedbacksController#create`, that is the breaking change
|
|
23
|
+
in this release. The per-IP rate limiter moved with the action.
|
|
24
|
+
- **Migrations follow the host's `primary_key_type`,** the same
|
|
25
|
+
`Rails.configuration.generators` lookup Rails' own Active Storage migration
|
|
26
|
+
does. A uuid-keyed app has a uuid `active_storage_attachments.record_id`, so a
|
|
27
|
+
bigint table here could never hold a screenshot: `attach` raised
|
|
28
|
+
`NotNullViolation`. A host that set nothing gets an identical migration.
|
|
29
|
+
- A `BackboneTest` now fails the build on any of the above regressing.
|
|
30
|
+
|
|
31
|
+
## 0.7.8
|
|
32
|
+
|
|
33
|
+
- Adds `AGENTS.md`: install and integration instructions written for coding
|
|
34
|
+
agents — the request-shaped config lambdas, the `/feedback` mount path, why
|
|
35
|
+
the status strings are not an enum, and the mistakes agents actually make. It
|
|
36
|
+
ships inside the gem, so `cat "$(bundle show ideasbugs)/AGENTS.md"` works from
|
|
37
|
+
a host app.
|
|
38
|
+
- The dummy app pins `queue_adapter = :test` for the test suite. Attaching a
|
|
39
|
+
screenshot enqueues Active Storage's analysis job, and the default `:async`
|
|
40
|
+
adapter runs it on a background thread with its own database connection —
|
|
41
|
+
writes no test transaction covers, which is how a suite starts failing
|
|
42
|
+
order-dependently in a test that never created a row. No effect on the gem
|
|
43
|
+
itself.
|
|
44
|
+
|
|
3
45
|
## 0.7.7 (2026-08-01)
|
|
4
46
|
|
|
5
47
|
- Added `Ideasbugs::Seeds.load!` and a `rake ideasbugs:seed_demo` task that
|
data/README.md
CHANGED
|
@@ -64,6 +64,11 @@ duplicating them.
|
|
|
64
64
|
Ruby >= 3.2 · Rails >= 7.1 · Active Storage only if you want screenshots ·
|
|
65
65
|
CSRF token comes from `csrf_meta_tags`, already in a standard Rails layout.
|
|
66
66
|
|
|
67
|
+
Installing with a coding agent? Point it at [AGENTS.md](AGENTS.md) — the same
|
|
68
|
+
steps in the order an agent needs them, plus the gates it tends to get wrong and
|
|
69
|
+
the things it should not do. It ships inside the gem, so
|
|
70
|
+
`cat "$(bundle show ideasbugs)/AGENTS.md"` works from any app that bundles it.
|
|
71
|
+
|
|
67
72
|
## What you get
|
|
68
73
|
|
|
69
74
|
| | |
|
|
@@ -97,6 +102,8 @@ Everything is optional — a fresh install works with zero config. In
|
|
|
97
102
|
| Option | Default | What it does |
|
|
98
103
|
| --------------------- | --------------------------- | --------------------------------------------------- |
|
|
99
104
|
| `authorize_admin` | development only | **Who can read the dashboard.** Override before deploying |
|
|
105
|
+
| `base_controller_class` | `ActionController::Base` | Controller the dashboard inherits — name your admin's and it adopts its layout, helpers and auth |
|
|
106
|
+
| `admin_layout` | the gem's own | Just the shell, if you don't want the whole controller |
|
|
100
107
|
| `enabled` | everyone | Who can send feedback. `false` hides the widget and rejects posts |
|
|
101
108
|
| `current_user` | `nil` | Attribute a submission to a user. Receives the request |
|
|
102
109
|
| `author_label` | the user's `email` | Short label stored and shown in the dashboard |
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ideasbugs
|
|
4
|
+
# Who is asking, which tenant they are in, and the gates that answer both.
|
|
5
|
+
#
|
|
6
|
+
# A concern rather than inherited behaviour because the engine has two
|
|
7
|
+
# controller roots: the public endpoints hang off ActionController::Base, and
|
|
8
|
+
# the dashboard hangs off whatever the host set as `base_controller_class`.
|
|
9
|
+
module RequestContext
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def ideasbugs_admin_layout
|
|
15
|
+
Ideasbugs.config.admin_layout
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def current_author
|
|
19
|
+
return @current_author if defined?(@current_author)
|
|
20
|
+
|
|
21
|
+
@current_author = Ideasbugs.config.current_user.call(request)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# The tenant for this request (nil = the single global board). Every read
|
|
25
|
+
# and write scopes to it, so a resolved-tenant admin only ever sees and
|
|
26
|
+
# writes their own tenant's feedback.
|
|
27
|
+
def current_tenant
|
|
28
|
+
return @current_tenant if defined?(@current_tenant)
|
|
29
|
+
|
|
30
|
+
@current_tenant = Ideasbugs.tenant(request)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def require_enabled
|
|
34
|
+
head :forbidden unless Ideasbugs.enabled?(request)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Server-side gate for the dashboard. Default: development only.
|
|
38
|
+
def require_admin
|
|
39
|
+
return if Ideasbugs.admin?(request)
|
|
40
|
+
|
|
41
|
+
render plain: 'Forbidden. Set Ideasbugs.config.authorize_admin to grant access.',
|
|
42
|
+
status: :forbidden
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Every dashboard query starts here, so an admin can only ever load,
|
|
46
|
+
# triage, or delete feedback in their own tenant — a cross-tenant id 404s.
|
|
47
|
+
def tenant_scope
|
|
48
|
+
Feedback.for_tenant(current_tenant)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ideasbugs
|
|
4
|
+
# Root of the engine's PUBLIC surface: widget.js and the submission endpoint.
|
|
5
|
+
# These stay on a plain ActionController::Base deliberately — someone filing a
|
|
6
|
+
# bug report must not be routed through a host's admin controller, which would
|
|
7
|
+
# demand a staff session for the widget.
|
|
8
|
+
#
|
|
9
|
+
# The dashboard's root is DashboardController, and that is where
|
|
10
|
+
# `config.base_controller_class` applies.
|
|
4
11
|
class ApplicationController < ActionController::Base
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
private
|
|
8
|
-
|
|
9
|
-
def ideasbugs_admin_layout
|
|
10
|
-
Ideasbugs.config.admin_layout
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def current_author
|
|
14
|
-
return @current_author if defined?(@current_author)
|
|
12
|
+
include RequestContext
|
|
15
13
|
|
|
16
|
-
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# The tenant for this request (nil = the single global board). Every read
|
|
20
|
-
# and write scopes to it, so a resolved-tenant admin only ever sees and
|
|
21
|
-
# writes their own tenant's feedback.
|
|
22
|
-
def current_tenant
|
|
23
|
-
return @current_tenant if defined?(@current_tenant)
|
|
24
|
-
|
|
25
|
-
@current_tenant = Ideasbugs.tenant(request)
|
|
26
|
-
end
|
|
14
|
+
protect_from_forgery with: :exception
|
|
27
15
|
end
|
|
28
16
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ideasbugs
|
|
4
|
+
# Root of the STAFF surface: the triage queue and the screenshot proxy.
|
|
5
|
+
#
|
|
6
|
+
# Inherits from `config.base_controller_class` — by default a plain
|
|
7
|
+
# ActionController::Base, which is why `authorize_admin` exists. Point it at
|
|
8
|
+
# the controller your own admin already inherits from and the dashboard picks
|
|
9
|
+
# up that stack wholesale: your layout, your helpers, your authentication, and
|
|
10
|
+
# whatever request context your before_actions establish.
|
|
11
|
+
#
|
|
12
|
+
# Only the dashboard hangs off it. The widget's endpoint stays on
|
|
13
|
+
# ApplicationController, so wiring an admin base controller here can never
|
|
14
|
+
# demand a staff session from someone filing a report.
|
|
15
|
+
class DashboardController < Ideasbugs.base_controller
|
|
16
|
+
include RequestContext
|
|
17
|
+
|
|
18
|
+
# A host base controller brings its own layout, and declaring one here would
|
|
19
|
+
# override it. So the gem only claims the layout when it owns the decision:
|
|
20
|
+
# no host base controller, or a host that named an `admin_layout` explicitly.
|
|
21
|
+
layout :ideasbugs_admin_layout unless superclass != ActionController::Base &&
|
|
22
|
+
Ideasbugs.config.admin_layout == Configuration::DEFAULT_ADMIN_LAYOUT
|
|
23
|
+
|
|
24
|
+
before_action :require_admin
|
|
25
|
+
|
|
26
|
+
# A host base controller has configured CSRF already; declaring it twice
|
|
27
|
+
# would run the check twice.
|
|
28
|
+
protect_from_forgery with: :exception if superclass == ActionController::Base
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,27 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Ideasbugs
|
|
4
|
-
class FeedbacksController <
|
|
4
|
+
class FeedbacksController < DashboardController
|
|
5
5
|
PER_PAGE = 50
|
|
6
6
|
|
|
7
|
-
layout :ideasbugs_admin_layout, except: :create
|
|
8
|
-
|
|
9
7
|
# The widget posts here; everything else is the triage dashboard.
|
|
10
|
-
before_action :require_enabled, only: :create
|
|
11
|
-
before_action :require_admin, except: :create
|
|
12
8
|
before_action :set_feedback, only: %i[show update destroy]
|
|
13
9
|
|
|
14
|
-
# Throttle the public endpoint per IP so one user or bot can't flood the
|
|
15
|
-
# table (each submission may carry megabytes of screenshots). Uses the
|
|
16
|
-
# rate limiter built into Rails 7.2+ (backed by Rails.cache); on Rails 7.1
|
|
17
|
-
# this is a no-op. Tune or disable via config.rate_limit — read once at
|
|
18
|
-
# boot, after the host's initializer.
|
|
19
|
-
if respond_to?(:rate_limit) && Ideasbugs.config.rate_limit
|
|
20
|
-
rate_limit(**Ideasbugs.config.rate_limit,
|
|
21
|
-
only: :create,
|
|
22
|
-
with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
|
|
23
|
-
end
|
|
24
|
-
|
|
25
10
|
def index
|
|
26
11
|
@status = Feedback::STATUSES.include?(params[:status]) ? params[:status] : 'open'
|
|
27
12
|
@kind = Ideasbugs.config.kinds.map(&:to_s).include?(params[:kind]) ? params[:kind] : nil
|
|
@@ -46,23 +31,6 @@ module Ideasbugs
|
|
|
46
31
|
|
|
47
32
|
def show; end
|
|
48
33
|
|
|
49
|
-
def create
|
|
50
|
-
feedback = Feedback.new(feedback_params)
|
|
51
|
-
feedback.user_agent = request.user_agent
|
|
52
|
-
feedback.tenant = current_tenant
|
|
53
|
-
attribute_author(feedback)
|
|
54
|
-
|
|
55
|
-
error = attach_screenshots(feedback)
|
|
56
|
-
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
57
|
-
|
|
58
|
-
if feedback.save
|
|
59
|
-
notify_host(feedback)
|
|
60
|
-
head :created
|
|
61
|
-
else
|
|
62
|
-
render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
|
|
66
34
|
def update
|
|
67
35
|
@feedback.update!(params.require(:feedback).permit(:status))
|
|
68
36
|
redirect_back fallback_location: feedback_path(@feedback), status: :see_other
|
|
@@ -75,28 +43,10 @@ module Ideasbugs
|
|
|
75
43
|
|
|
76
44
|
private
|
|
77
45
|
|
|
78
|
-
def require_enabled
|
|
79
|
-
head :forbidden unless Ideasbugs.enabled?(request)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Server-side gate for the dashboard. Default: development only.
|
|
83
|
-
def require_admin
|
|
84
|
-
return if Ideasbugs.admin?(request)
|
|
85
|
-
|
|
86
|
-
render plain: 'Forbidden. Set Ideasbugs.config.authorize_admin to grant access.',
|
|
87
|
-
status: :forbidden
|
|
88
|
-
end
|
|
89
|
-
|
|
90
46
|
def set_feedback
|
|
91
47
|
@feedback = tenant_scope.find(params[:id])
|
|
92
48
|
end
|
|
93
49
|
|
|
94
|
-
# Every dashboard query starts here, so an admin can only ever load,
|
|
95
|
-
# triage, or delete feedback in their own tenant — a cross-tenant id 404s.
|
|
96
|
-
def tenant_scope
|
|
97
|
-
Feedback.for_tenant(current_tenant)
|
|
98
|
-
end
|
|
99
|
-
|
|
100
50
|
# Case-insensitive match on the free-text columns. LOWER() keeps it
|
|
101
51
|
# portable across SQLite/PostgreSQL/MySQL, and the explicit ESCAPE makes
|
|
102
52
|
# the sanitized backslash escapes work on SQLite, which has no default
|
|
@@ -109,62 +59,5 @@ module Ideasbugs
|
|
|
109
59
|
q: pattern
|
|
110
60
|
)
|
|
111
61
|
end
|
|
112
|
-
|
|
113
|
-
# The host's hook must never turn a saved submission into a 500 — the
|
|
114
|
-
# feedback is in the database; notification failures are the host's logs'
|
|
115
|
-
# problem.
|
|
116
|
-
def notify_host(feedback)
|
|
117
|
-
Ideasbugs.config.on_submit.call(feedback)
|
|
118
|
-
rescue StandardError => e
|
|
119
|
-
Rails.logger.error("ideasbugs: on_submit hook raised #{e.class}: #{e.message}")
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def feedback_params
|
|
123
|
-
params.require(:feedback).permit(:kind, :section, :message, :page_url)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def attribute_author(feedback)
|
|
127
|
-
author = current_author
|
|
128
|
-
return unless author
|
|
129
|
-
|
|
130
|
-
feedback.author_id = author.id.to_s if author.respond_to?(:id)
|
|
131
|
-
feedback.author_label = Ideasbugs.config.author_label.call(author)
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Validates and attaches uploads. Returns an error message, or nil when
|
|
135
|
-
# everything (including "no screenshots at all") is fine.
|
|
136
|
-
def attach_screenshots(feedback)
|
|
137
|
-
files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
|
|
138
|
-
return nil if files.empty?
|
|
139
|
-
return t_error(:error_save) unless Ideasbugs.config.screenshots_enabled?
|
|
140
|
-
return t_error(:error_too_many, count: Ideasbugs.config.max_screenshots) if too_many?(files)
|
|
141
|
-
return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
|
|
142
|
-
return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
|
|
143
|
-
|
|
144
|
-
feedback.screenshots.attach(files)
|
|
145
|
-
nil
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def too_many?(files)
|
|
149
|
-
files.size > Ideasbugs.config.max_screenshots
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def max_size
|
|
153
|
-
Ideasbugs.config.max_screenshot_size
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def max_size_mb
|
|
157
|
-
max_size / (1024 * 1024)
|
|
158
|
-
end
|
|
159
|
-
|
|
160
|
-
def t_error(key, **args)
|
|
161
|
-
defaults = {
|
|
162
|
-
error_save: 'Could not send feedback. Please try again.',
|
|
163
|
-
error_too_many: 'Too many screenshots (max %{count}).',
|
|
164
|
-
error_too_large: 'A screenshot is too large (max %{size} MB).',
|
|
165
|
-
error_rate_limited: 'Too many submissions. Please wait a moment and try again.'
|
|
166
|
-
}
|
|
167
|
-
I18n.t(key, scope: :ideasbugs, default: defaults[key], **args)
|
|
168
|
-
end
|
|
169
62
|
end
|
|
170
63
|
end
|
|
@@ -6,9 +6,7 @@ module Ideasbugs
|
|
|
6
6
|
# screenshots can contain anything a user's screen showed, so they must never
|
|
7
7
|
# be reachable without passing the same gate as the dashboard — regardless of
|
|
8
8
|
# how the host app configures (or doesn't configure) blob access.
|
|
9
|
-
class ScreenshotsController <
|
|
10
|
-
before_action :require_admin
|
|
11
|
-
|
|
9
|
+
class ScreenshotsController < DashboardController
|
|
12
10
|
def show
|
|
13
11
|
screenshot = Feedback.for_tenant(current_tenant)
|
|
14
12
|
.find(params[:feedback_id]).screenshots.find(params[:id])
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ideasbugs
|
|
4
|
+
# The widget's write endpoint: POST /feedbacks.
|
|
5
|
+
#
|
|
6
|
+
# Public, so it stays on ApplicationController and never inherits a host's
|
|
7
|
+
# admin base controller — someone filing a bug report must not be asked for a
|
|
8
|
+
# staff session. The triage actions live in FeedbacksController, which does
|
|
9
|
+
# inherit it.
|
|
10
|
+
class SubmissionsController < ApplicationController
|
|
11
|
+
before_action :require_enabled
|
|
12
|
+
|
|
13
|
+
# Throttle the public endpoint per IP so one user or bot can't flood the
|
|
14
|
+
# table (each submission may carry megabytes of screenshots). Uses the
|
|
15
|
+
# rate limiter built into Rails 7.2+ (backed by Rails.cache); on Rails 7.1
|
|
16
|
+
# this is a no-op. Tune or disable via config.rate_limit — read once at
|
|
17
|
+
# boot, after the host's initializer.
|
|
18
|
+
if respond_to?(:rate_limit) && Ideasbugs.config.rate_limit
|
|
19
|
+
rate_limit(**Ideasbugs.config.rate_limit,
|
|
20
|
+
only: :create,
|
|
21
|
+
with: -> { render json: { errors: [t_error(:error_rate_limited)] }, status: :too_many_requests })
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def create
|
|
25
|
+
feedback = Feedback.new(feedback_params)
|
|
26
|
+
feedback.user_agent = request.user_agent
|
|
27
|
+
feedback.tenant = current_tenant
|
|
28
|
+
attribute_author(feedback)
|
|
29
|
+
|
|
30
|
+
error = attach_screenshots(feedback)
|
|
31
|
+
return render json: { errors: [error] }, status: :unprocessable_entity if error
|
|
32
|
+
|
|
33
|
+
if feedback.save
|
|
34
|
+
notify_host(feedback)
|
|
35
|
+
head :created
|
|
36
|
+
else
|
|
37
|
+
render json: { errors: feedback.errors.full_messages }, status: :unprocessable_entity
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
# The host's hook must never turn a saved submission into a 500 — the
|
|
44
|
+
# feedback is in the database; notification failures are the host's logs'
|
|
45
|
+
# problem.
|
|
46
|
+
def notify_host(feedback)
|
|
47
|
+
Ideasbugs.config.on_submit.call(feedback)
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Rails.logger.error("ideasbugs: on_submit hook raised #{e.class}: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def feedback_params
|
|
53
|
+
params.require(:feedback).permit(:kind, :section, :message, :page_url)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def attribute_author(feedback)
|
|
57
|
+
author = current_author
|
|
58
|
+
return unless author
|
|
59
|
+
|
|
60
|
+
feedback.author_id = author.id.to_s if author.respond_to?(:id)
|
|
61
|
+
feedback.author_label = Ideasbugs.config.author_label.call(author)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validates and attaches uploads. Returns an error message, or nil when
|
|
65
|
+
# everything (including "no screenshots at all") is fine.
|
|
66
|
+
def attach_screenshots(feedback)
|
|
67
|
+
files = Array(params.dig(:feedback, :screenshots)).reject(&:blank?)
|
|
68
|
+
return nil if files.empty?
|
|
69
|
+
return t_error(:error_save) unless Ideasbugs.config.screenshots_enabled?
|
|
70
|
+
return t_error(:error_too_many, count: Ideasbugs.config.max_screenshots) if too_many?(files)
|
|
71
|
+
return t_error(:error_too_large, size: max_size_mb) if files.any? { |f| f.size > max_size }
|
|
72
|
+
return t_error(:error_save) unless files.all? { |f| f.content_type.to_s.start_with?('image/') }
|
|
73
|
+
|
|
74
|
+
feedback.screenshots.attach(files)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def too_many?(files)
|
|
79
|
+
files.size > Ideasbugs.config.max_screenshots
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def max_size_mb
|
|
83
|
+
max_size / (1024 * 1024)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def max_size
|
|
87
|
+
Ideasbugs.config.max_screenshot_size
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def t_error(key, **args)
|
|
91
|
+
defaults = {
|
|
92
|
+
error_save: 'Could not send feedback. Please try again.',
|
|
93
|
+
error_too_many: 'Too many screenshots (max %{count}).',
|
|
94
|
+
error_too_large: 'A screenshot is too large (max %{size} MB).',
|
|
95
|
+
error_rate_limited: 'Too many submissions. Please wait a moment and try again.'
|
|
96
|
+
}
|
|
97
|
+
I18n.t(key, scope: :ideasbugs, default: defaults[key], **args)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|