Files
sm-feedback-plugin/README.md
Jakub Zych af5d77c997 feat: G15Office job, widget settings and submissions admin, embed.js and the settings importer
- SyncG15Office files each submission as a G15Office task with its screenshot through a fetchguard client limited to the configured host; a River job with 3 attempts, PHP's messages on failure, nothing sent when unconfigured
- the replay of PHP's recorded G15Office exchange (classes/testdata) asserts the task JSON, headers and the screenshot part
- settings screen (cabana field types only), read-only submissions list, manage_settings permission and navigation
- embed.js served byte for byte at /plugins/golem15/feedback/assets/js/embed.js
- feedback:import-settings copies the WinterCMS settings row once
2026-10-04 00:22:08 +02:00

116 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. 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).