feat: feedback submissions with screenshots, the hide-widget preference and its user payload key

- POST /_feedback/api/v1/{key}/submit validates as PHP, refuses non-image screenshots (ImageContentGuard copy) and stores the submission, its public screenshot and the G15Office job in one transaction
- PUT /_feedback/api/v1/me/hidden (jwt.auth) stores the caller's preference; feedback_widget_hidden joins every golem15.user payload
- preflights stay with the framework CORS layer
This commit is contained in:
Jakub Zych
2026-10-04 00:10:15 +02:00
parent b4b4135ad3
commit 92e1b4e09c
12 changed files with 1073 additions and 6 deletions

View File

@@ -20,6 +20,9 @@ The plugin ID, the table names, the config keys and the settings code are a cont
- **Widget config.** `GET /_feedback/api/v1/{key}/config` answers the widget's position, whether visitors may hide it, its colors and its labels (`?lang=en` selects the English labels, anything else the Polish ones). A disabled widget, an empty key or a key that differs (compared in constant time) is 404 `{"error":true,"message":"Not found"}`.
- **Origin gate.** A request without an `Origin` header passes. An `Origin` passes only when its URL host, lower-cased, is on the allowed-origins list (one entry per line; a line is reduced to its URL host when it has one). An empty list refuses every `Origin` with 403 `{"error":true,"message":"Origin not allowed"}`, so a widget that was never configured cannot be embedded anywhere.
- **Submissions.** `POST /_feedback/api/v1/{key}/submit` (multipart, from the widget) passes the same key and Origin gate, then validates the report as the WinterCMS plugin does: `message` (required, up to 5000 characters), `type` (`bug`, `feature` or `other`), `email` (optional, up to 255), `page_url` (required, up to 2000), `user_agent` (up to 500), `console_log` (up to 20000) and `screenshot` (an optional JPEG, PNG, GIF or WebP of up to 10240 KB). A failure is 422 `{"error":true,"message":"Validation failed","details":{...}}`. A screenshot whose bytes do not decode as one of those images (`classes.IsAllowedImage`) is 422 with `details.screenshot = ["The file is not a valid image."]`. The submission (status `pending`), its public screenshot (attachOne `screenshot`) and its G15Office job are written in one transaction, and the answer is 202 `{"success":true}`: the visitor never waits on G15Office.
- **Hiding the widget.** `PUT /_feedback/api/v1/me/hidden` (behind golem15.user's `jwt.auth`) stores the signed-in user's preference: `hidden` must be a Laravel boolean (`true`, `false`, `1`, `0`, `"1"`, `"0"`), else 422 `{"error":"Validation failed","errors":{...}}`. The answer is `{"hidden":bool}`. Every golem15.user payload (fetch, login, update) carries `feedback_widget_hidden` through a `GetApiArrayEvent` listener: the stored value, false without a row.
- **CORS.** The widget's preflights are answered by the framework's path-scoped CORS layer, so the plugin registers no `OPTIONS` route.
- **Rate limits.** The named buckets `feedback-config` (60 a minute) and `feedback-submit` (10 a minute) count per client IP behind the configured trusted proxies.
- **Settings.** `models.Settings` is the singleton row of `golem15_feedback_settings`. `models.Instance` creates it on first access with the WinterCMS defaults and a new widget key (`wk_` plus 32 random letters and digits).
@@ -60,7 +63,8 @@ The plugin's own `go.mod` replaces the framework with `../../../../summercms.go`
## API reference
- `feedback`: `Plugin`, the `golem15.feedback` plugin (config, lang, migrations, models, routes, buckets); `BucketConfig`, `BucketSubmit`.
- `controllers/api`: `Config`, `KeyMatches`, `OriginAllowed`.
- `controllers/api`: `Config`, `Submit`, `SubmitRules`, `MeHidden`, `MeHiddenRules`, `KeyMatches`, `OriginAllowed`.
- `classes`: `IsAllowedImage`, `SniffImageMIME`, `AllowedImageMIMEs`; `Screenshot`, `StoreSubmission`, `ScreenshotOf`; the job contract `SyncG15OfficeArgs`, `SyncG15OfficeKind`, `SyncG15OfficeQueue`, `SyncG15OfficeLabel`, `SyncG15OfficeAttempts`.
- `models`: `Settings` (table `golem15_feedback_settings`), `SettingsID`, `SettingsCode`, `DefaultSettings`, `Instance`, `NewWidgetKey`, `Labels`, `URLHost`, `ASCIILower`; `Submission` (table `golem15_feedback_submissions`), `StatusPending`, `StatusSent`, `StatusFailed`, `TypeBug`, `TypeFeature`, `TypeOther`, `ScreenshotField`, `G15OfficeTypeMap`; `UserPreference` (table `golem15_feedback_user_preferences`), `IsWidgetHidden`, `SetWidgetHidden`; `Register`, `All`.
- `updates`: the migrations (`202610030201_create_feedback_submissions`, `202610030202_create_feedback_user_preferences`, `202610030203_create_feedback_settings`), `Register`, `All`.
@@ -78,7 +82,8 @@ Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOL
## Dependencies
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `compass`, `lagoon`, `pact`, `party`, `surf`, `wire`).
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bouncer`, `compass`, `conga`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`).
- gocloud.dev blob storage for the screenshots, through the application's uploads bucket.
- The user plugin, `git.golem15.com/golem15/sm-user-plugin`.
- GORM and gormigrate, as pinned in `go.mod`.
- testcontainers-go for the Postgres-backed tests.