A retry after a failed attachment or a failed CompleteJob created another task for the same report. The task id is now stored as soon as the task is created, a retry with a stored id only re-attempts the attachment, a sent submission with a task id is skipped, and a CompleteJob failure is logged instead of returned.
116 lines
11 KiB
Markdown
116 lines
11 KiB
Markdown
# golem15.feedback
|
||
|
||
The shared feedback-widget plugin for SummerCMS applications: an embeddable widget's public config and submit API behind a widget key and an Origin allow-list, a per-user "hide the widget" preference, the widget settings, and a job that files every submission as a G15Office task.
|
||
|
||
```go
|
||
import _ "git.golem15.com/golem15/sm-feedback-plugin"
|
||
```
|
||
|
||
Module `git.golem15.com/golem15/sm-feedback-plugin`, package `feedback`, plugin ID `golem15.feedback`.
|
||
|
||
## Overview
|
||
|
||
The plugin is a compiled SummerCMS plugin ported from the WinterCMS `Golem15.Feedback` plugin. Its `init` registers it with the framework, so an application loads it by listing it in `summer.yaml`; `summer build` then emits the blank import above into the application's `plugins.gen.go`. It requires `golem15.user`.
|
||
|
||
The widget routes are public. The widget key in the URL is a public identifier (it ships in the embed snippet), not a secret: the real gate is the Origin check against the operator's allowed-origins list, backed by per-IP rate limits.
|
||
|
||
The plugin ID, the table names, the config keys and the settings code are a contract shared by every application that mounts the plugin: change them only in a backwards-compatible way.
|
||
|
||
## Features
|
||
|
||
- **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). Operators edit it under Settings › Feedback Widget (code `feedback`, permission `golem15.feedback.manage_settings`): the WinterCMS form with cabana's field types, the colors as text fields and the widget key read-only. A row saved without a key gets a new one.
|
||
- **G15Office tasks.** Every submission queues a `classes.SyncG15OfficeArgs` job (kind `golem15.feedback.sync_g15office`, queue `feedback`, 3 attempts, a `summer_jobs` row labelled with the WinterCMS job class). `classes.SyncG15Office` files it with `classes.G15OfficeClient`: `POST <base>/_support/api/v1/projects/<project>/tasks` with the title (`Feedback: ` and the message collapsed and cut to 80 columns), a Markdown description (the message, the page, the reporter email and user agent, the console log cut to 4000 columns), the task type (`bug` → Bug, `feature` → Feature, `other` → Task), the configured task status and priority; then `POST <base>/_support/api/v1/tasks/<id>/attachments` with the screenshot as the multipart field `file`. Requests carry `Authorization: Bearer <token>` and `Accept: application/json` and go through a `fetchguard.Client` limited to the base URL's host (https only). Success marks the submission `sent` with the task id; a failure marks it `failed` with the error and the job is retried. The task id is stored as soon as the task is created, so a retry re-attempts only the attachment, and a submission already `sent` with a task id is never filed again. Without a base URL, token and project nothing is sent: the job fails with the WinterCMS "not configured" message.
|
||
- **Submissions list.** Admin › Feedback › Submissions (controller `golem15.feedback.submissions`) is a read-only list: type, status, reporter email, page, message, task id and date, all as plain text.
|
||
- **Widget script.** `GET /plugins/golem15/feedback/assets/js/embed.js` serves the WinterCMS plugin's `embed.js` byte for byte (`application/javascript`, with an ETag), at the path embedding pages already use.
|
||
- **Settings import.** `feedback:import-settings` copies a WinterCMS installation's widget settings, the widget key included, into the singleton once.
|
||
|
||
## Usage
|
||
|
||
Mount the plugin in an application as a git submodule at `plugins/golem15/feedback`:
|
||
|
||
```sh
|
||
git submodule add git@git.golem15.com:golem15/sm-feedback-plugin.git plugins/golem15/feedback
|
||
```
|
||
|
||
Add it to the application's `go.work` and `go.mod`:
|
||
|
||
```text
|
||
use ./plugins/golem15/feedback
|
||
|
||
require git.golem15.com/golem15/sm-feedback-plugin v0.0.0
|
||
|
||
replace git.golem15.com/golem15/sm-feedback-plugin => ./plugins/golem15/feedback
|
||
```
|
||
|
||
List it in `summer.yaml` after `golem15.user` and run `summer build`:
|
||
|
||
```yaml
|
||
module: git.golem15.com/golem15/acme
|
||
binary: acme
|
||
plugins:
|
||
- id: golem15.user
|
||
module: git.golem15.com/golem15/sm-user-plugin
|
||
- id: golem15.feedback
|
||
module: git.golem15.com/golem15/sm-feedback-plugin
|
||
```
|
||
|
||
The application's `http.cors.paths` must list `_feedback/api/*`: the widget calls the API from other origins. A page embeds the widget with its key:
|
||
|
||
```html
|
||
<script src="https://backend.example.com/plugins/golem15/feedback/assets/js/embed.js" data-key="wk_..." data-lang="en" defer></script>
|
||
```
|
||
|
||
Set the G15Office connection with the three `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__*` variables (see Configuration) and run a queue worker: `serve` runs one in-process when `queue.work_in_serve` is on, `queue:work` in its own process. After moving an installation from WinterCMS, run `feedback:import-settings` once so embed snippets keep their widget key.
|
||
|
||
The plugin's own `go.mod` replaces the framework with `../../../../summercms.go` and the user plugin with `../user`, so it expects the framework checkout next to the application checkout and the user plugin mounted beside it.
|
||
|
||
## API reference
|
||
|
||
- `feedback`: `Plugin`, the `golem15.feedback` plugin (config, lang, migrations, models, routes, buckets, jobs, commands, admin controllers, permissions, navigation, settings); `BucketConfig`, `BucketSubmit`, `EmbedJSPath`, `EmbedJS`, `SettingsCode`.
|
||
- `controllers/api`: `Config`, `Submit`, `SubmitRules`, `MeHidden`, `MeHiddenRules`, `KeyMatches`, `OriginAllowed`.
|
||
- `classes`: `IsAllowedImage`, `SniffImageMIME`, `AllowedImageMIMEs`; `Screenshot`, `StoreSubmission`, `ScreenshotOf`; the job contract `SyncG15OfficeArgs`, `SyncG15OfficeKind`, `SyncG15OfficeQueue`, `SyncG15OfficeLabel`, `SyncG15OfficeAttempts`; the job `SyncG15Office`, `SyncDeps`, `TaskTitle`, `TaskDescription`, `StrLimit`, `StrWidth`; the client `G15OfficeClient`, `NewG15OfficeClient`, `NewG15OfficeClientWith`, `TaskFields`, `ErrNotConfigured`, `G15OfficeTimeout`, `ConfigBaseURL`, `ConfigToken`, `ConfigProject`.
|
||
- `controllers`: `SubmissionsAdminController`, `AdminControllers`, `PermissionManageSettings`.
|
||
- `console`: `ImportSettings`, `Import`, `IsNothingToImport`, `ErrSettingsCustomised`.
|
||
- `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`.
|
||
|
||
## Configuration
|
||
|
||
Defaults live in `config/config.yaml` and are merged under `golem15.feedback.*`.
|
||
|
||
| Key | Default | Meaning |
|
||
|-----|---------|---------|
|
||
| `golem15.feedback.g15_office.base_url` | empty | The G15Office base URL submissions are filed under (https; requests may reach only its host). |
|
||
| `golem15.feedback.g15_office.token` | empty | The G15Office API token. Never commit it. |
|
||
| `golem15.feedback.g15_office.project` | empty | The G15Office project the tasks are created in. |
|
||
|
||
Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__TOKEN` and `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__PROJECT`.
|
||
|
||
## CLI commands
|
||
|
||
| Command | Purpose |
|
||
|---------|---------|
|
||
| `feedback:import-settings [--force]` | Copy the WinterCMS `system_settings` row `golem15_feedback_settings` (widget key, origins, colors, labels, G15Office status and priority) into `golem15_feedback_settings`. It refuses settings that were already changed unless `--force` is given, and reports a missing `system_settings` table or row without failing. |
|
||
|
||
## Dependencies
|
||
|
||
- The SummerCMS framework, `git.golem15.com/golem15/summercms` (modules `backpack`, `bonfire`, `bouncer`, `cabana`, `compass`, `conga`, `fetchguard`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`; `tide` in tests).
|
||
- 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.
|
||
|
||
## Testing
|
||
|
||
```sh
|
||
go test ./... # starts Postgres with testcontainers (needs Docker)
|
||
```
|
||
|
||
`classes/testdata/g15office-task.upstream.yaml` is the G15Office exchange the WinterCMS job made, recorded through the framework's upstream recording proxy; `TestSyncG15Office` replays it offline and fails on any difference in the requests (URL, headers, the task JSON, the screenshot part).
|