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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e054b5c1fcb3a26c77783e54d044550ca8a395bad3189540451b79498e36fe0b
4
- data.tar.gz: 567787250431c93054d02c0cca2a0366515138c35f9d2ac7a9ed87162d318d9f
3
+ metadata.gz: 19b7014d62b42ad3b3a763edf8907a7e1e0d2a67ab7b5ae1001d1f3440cc0d67
4
+ data.tar.gz: 0f327125389c6ccc2d57f180b09d24607f383d03ac1d70ad13c9c250d3670842
5
5
  SHA512:
6
- metadata.gz: f8542b1c572317d929ae744c2caaeb2e0e59240ad68e740c290d3a15ab4761b9a55cf54b4332b6334667585f45603f9ce9444a3362ccc0a7762ab9c856ee330e
7
- data.tar.gz: 8da898919668edb4d3f7d87e76e4223a295bea3374c8e7a355b446277ed08b8983606aecb81544ccf765e1640d26c34f01febaf447bc1950b0e1aa89a771e7fc
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
- protect_from_forgery with: :exception
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
- @current_author = Ideasbugs.config.current_user.call(request)
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 < ApplicationController
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 < ApplicationController
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