diff --git a/README.md b/README.md
index f551341..55f5ae6 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,11 @@ The plugin ID, the table names, the config keys and the settings code are a cont
- **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).
+- **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 /_support/api/v1/projects//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 /_support/api/v1/tasks//attachments` with the screenshot as the multipart field `file`. Requests carry `Authorization: Bearer ` 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
@@ -56,15 +60,23 @@ plugins:
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.
+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
+
+```
+
+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); `BucketConfig`, `BucketSubmit`.
+- `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`.
+- `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`.
@@ -74,15 +86,21 @@ 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. |
+| `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`, `bouncer`, `compass`, `conga`, `lagoon`, `pact`, `party`, `phrasebook`, `surf`, `wire`).
+- 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`.
@@ -93,3 +111,5 @@ Override them with `SUMMER_GOLEM15__FEEDBACK__G15_OFFICE__BASE_URL`, `SUMMER_GOL
```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).
diff --git a/admin.go b/admin.go
new file mode 100644
index 0000000..85ffa34
--- /dev/null
+++ b/admin.go
@@ -0,0 +1,82 @@
+package feedback
+
+import (
+ "embed"
+ "io/fs"
+
+ "git.golem15.com/golem15/sm-feedback-plugin/controllers"
+ "git.golem15.com/golem15/sm-feedback-plugin/models"
+ "git.golem15.com/golem15/summercms/modules/pact"
+)
+
+//go:embed controllers/submissions/config_list.yaml models/submission/columns.yaml models/settings/fields.yaml
+var adminFS embed.FS
+
+var (
+ _ pact.AdminAssets = (*Plugin)(nil)
+ _ pact.HasAdminControllers = (*Plugin)(nil)
+ _ pact.HasPermissions = (*Plugin)(nil)
+ _ pact.HasNavigation = (*Plugin)(nil)
+ _ pact.HasSettings = (*Plugin)(nil)
+)
+
+// AdminFS is the admin YAML: the settings form and the submissions list.
+func (p *Plugin) AdminFS() fs.FS { return adminFS }
+
+// AdminControllers is the submissions list.
+func (p *Plugin) AdminControllers() []pact.AdminController {
+ return controllers.AdminControllers()
+}
+
+// Permissions is registerPermissions: golem15.feedback.manage_settings for
+// the developer role.
+func (*Plugin) Permissions() []pact.Permission {
+ return []pact.Permission{{
+ Code: controllers.PermissionManageSettings,
+ Tab: "golem15.feedback::lang.plugin.tab",
+ Label: "golem15.feedback::lang.settings.manage_settings",
+ Roles: []string{"developer"},
+ }}
+}
+
+// Navigation is the Feedback menu with its submissions entry.
+func (*Plugin) Navigation() []pact.NavigationItem {
+ return []pact.NavigationItem{{
+ Code: "feedback",
+ Label: "golem15.feedback::lang.plugin.menu_label",
+ Icon: "message-square",
+ Permissions: []string{"golem15.feedback.*"},
+ Order: 600,
+ Controller: "golem15.feedback.submissions",
+ SideMenu: []pact.NavigationItem{{
+ Code: "submissions",
+ Label: "golem15.feedback::lang.submissions.menu_label",
+ Icon: "inbox",
+ Permissions: []string{controllers.PermissionManageSettings},
+ Controller: "golem15.feedback.submissions",
+ }},
+ }}
+}
+
+// SettingsCode is the settings screen's code. WinterCMS keys the screen
+// by plugin and code ("settings"); cabana keys it by code alone across all
+// plugins, so the plugin's own name keeps it apart from other plugins'.
+const SettingsCode = "feedback"
+
+// Settings is registerSettings: the widget settings singleton
+// (golem15_feedback_settings) on cabana's settings screen.
+func (*Plugin) Settings() []pact.SettingsItem {
+ return []pact.SettingsItem{{
+ Code: SettingsCode,
+ Label: "golem15.feedback::lang.settings.menu_label",
+ Description: "golem15.feedback::lang.settings.settings_description",
+ Category: "golem15.feedback::lang.plugin.tab",
+ Icon: "message-square",
+ Model: `Golem15\Feedback\Models\Settings`,
+ Order: 500,
+ Keywords: []string{"feedback", "widget", "g15office"},
+ Permissions: []string{controllers.PermissionManageSettings},
+ Form: "models/settings/fields.yaml",
+ NewModel: func() any { return &models.Settings{} },
+ }}
+}
diff --git a/assets.go b/assets.go
new file mode 100644
index 0000000..5d29a79
--- /dev/null
+++ b/assets.go
@@ -0,0 +1,35 @@
+package feedback
+
+import (
+ "bytes"
+ "crypto/sha256"
+ _ "embed"
+ "encoding/hex"
+ "net/http"
+ "time"
+)
+
+// EmbedJSPath is where the widget script is served: the path the WinterCMS
+// plugin's file had under the web root, which embedding pages and the
+// application's frontend proxy request.
+const EmbedJSPath = "/plugins/golem15/feedback/assets/js/embed.js"
+
+// embedJS is the WinterCMS plugin's assets/js/embed.js, byte for byte.
+//
+//go:embed assets/js/embed.js
+var embedJS []byte
+
+var embedJSETag = func() string {
+ sum := sha256.Sum256(embedJS)
+ return `"` + hex.EncodeToString(sum[:16]) + `"`
+}()
+
+// EmbedJS serves the widget script with a JavaScript content type and an
+// ETag, answering conditional and range requests like a static file.
+func EmbedJS() http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
+ w.Header().Set("ETag", embedJSETag)
+ http.ServeContent(w, r, "embed.js", time.Time{}, bytes.NewReader(embedJS))
+ }
+}
diff --git a/assets/js/embed.js b/assets/js/embed.js
new file mode 100644
index 0000000..6c8c2bc
--- /dev/null
+++ b/assets/js/embed.js
@@ -0,0 +1,643 @@
+/*!
+ * Golem15.Feedback embed widget.
+ *
+ * One script tag, self-configuring from its own
+ *
+ * html2canvas is lazy-loaded from cdnjs (pinned version + SRI) only when the
+ * widget is actually opened — this file stays hand-written and small rather
+ * than vendoring a ~500KB third-party library inline with no build step to
+ * verify it against.
+ */
+(function () {
+ 'use strict';
+
+ var CURRENT_SCRIPT = document.currentScript;
+ var WIDGET_KEY = CURRENT_SCRIPT && CURRENT_SCRIPT.getAttribute('data-key');
+ var API_BASE = (function () {
+ var src = CURRENT_SCRIPT ? CURRENT_SCRIPT.src : '';
+ try {
+ return new URL(src).origin;
+ } catch (e) {
+ return '';
+ }
+ })();
+
+ if (!WIDGET_KEY || !API_BASE) {
+ return; // missing/misconfigured snippet — stay silent, never break the host page
+ }
+
+ // data-lang lets the host page sync the widget to its own current locale
+ // (e.g. Nuxt's i18n locale at page-load time — not reactive to an
+ // in-app language switch without a reload, since the script only reads
+ // this once). Anything other than 'en' falls back to 'pl'.
+ var LANG = (CURRENT_SCRIPT.getAttribute('data-lang') === 'en') ? 'en' : 'pl';
+
+ // Same idea as data-lang, for the per-user "hide this widget" preference:
+ // covers the state at page-load time (the host page already knows it —
+ // it rides along on the user's own /fetch response); window.G15Feedback
+ // .hide()/.show() handle every later change without a reload.
+ var pendingHidden = CURRENT_SCRIPT.getAttribute('data-hidden') === 'true';
+
+ var I18N = {
+ pl: {
+ title: 'Zgłoś opinię',
+ placeholder: 'Co poszło nie tak?',
+ emailPlaceholder: 'Email (opcjonalnie)',
+ typeLabel: 'Rodzaj zgłoszenia',
+ typeBug: 'Błąd',
+ typeFeature: 'Propozycja funkcji',
+ typeOther: 'Inne',
+ send: 'Wyślij',
+ cancel: 'Anuluj',
+ capturing: 'Przechwytywanie zrzutu ekranu…',
+ sending: 'Wysyłanie…',
+ pleaseDescribe: 'Opisz, co poszło nie tak.',
+ success: 'Dzięki za zgłoszenie!',
+ error: 'Nie udało się wysłać — spróbuj ponownie.',
+ hideWidget: 'Ukryj widget',
+ },
+ en: {
+ title: 'Send feedback',
+ placeholder: 'What went wrong?',
+ emailPlaceholder: 'Email (optional)',
+ typeLabel: 'Type',
+ typeBug: 'Bug',
+ typeFeature: 'Feature request',
+ typeOther: 'Other',
+ send: 'Send',
+ cancel: 'Cancel',
+ capturing: 'Capturing screenshot…',
+ sending: 'Sending…',
+ pleaseDescribe: 'Please describe the issue.',
+ success: 'Thanks for the feedback!',
+ error: 'Could not send feedback — please try again.',
+ hideWidget: 'Hide widget',
+ },
+ };
+
+ function t(key) {
+ return I18N[LANG][key];
+ }
+
+ var TYPE_KEY_MAP = { bug: 'typeBug', feature: 'typeFeature', other: 'typeOther' };
+
+ var API_ROOT = API_BASE + '/_feedback/api/v1/' + encodeURIComponent(WIDGET_KEY);
+
+ // html2canvas 1.4.1 (the upstream, unmaintained since 2022) throws on any
+ // element whose computed style uses a modern CSS color function —
+ // oklch(), oklab(), lab(), color-mix() — which is Tailwind v4's default
+ // palette encoding. That made every single capture on this app fail,
+ // regardless of page content: "Attempting to parse an unsupported color
+ // function 'oklch'". html2canvas-pro is an actively maintained,
+ // API-compatible fork that adds support for those functions; it still
+ // exposes itself as window.html2canvas.
+ var HTML2CANVAS_URL = 'https://cdn.jsdelivr.net/npm/html2canvas-pro@2.3.9/dist/html2canvas-pro.min.js';
+ var HTML2CANVAS_INTEGRITY = 'sha512-KGyDjROZQZxrvNCSjDB9/3H3nv6/Zl64VAmW4Dnb3oykEDApl27U+Md5h0p656d04uAnoGxB8KGUWsUI0u3sTA==';
+
+ // --- console log buffer -------------------------------------------------
+ // Wrapped as early as possible (top of this file, before any UI exists) so
+ // errors logged before the widget is ever opened are still captured.
+ var LOG_LIMIT = 50;
+ var consoleBuffer = [];
+
+ function pushLog(level, text) {
+ consoleBuffer.push('[' + level + '] ' + text);
+ if (consoleBuffer.length > LOG_LIMIT) {
+ consoleBuffer.shift();
+ }
+ }
+
+ ['log', 'warn', 'error'].forEach(function (level) {
+ var original = console[level];
+ console[level] = function () {
+ try {
+ var text = Array.prototype.slice.call(arguments).map(function (a) {
+ if (typeof a === 'string') {
+ return a;
+ }
+ try {
+ return JSON.stringify(a);
+ } catch (e) {
+ return String(a);
+ }
+ }).join(' ');
+ pushLog(level, text);
+ } catch (e) {
+ // never let logging itself throw
+ }
+ return original.apply(console, arguments);
+ };
+ });
+
+ window.addEventListener('error', function (event) {
+ pushLog('error', event.message + ' (' + event.filename + ':' + event.lineno + ')');
+ });
+
+ // --- html2canvas lazy loader --------------------------------------------
+ var html2canvasPromise = null;
+
+ function loadHtml2Canvas() {
+ if (window.html2canvas) {
+ return Promise.resolve(window.html2canvas);
+ }
+ if (html2canvasPromise) {
+ return html2canvasPromise;
+ }
+ html2canvasPromise = new Promise(function (resolve, reject) {
+ var script = document.createElement('script');
+ script.src = HTML2CANVAS_URL;
+ script.integrity = HTML2CANVAS_INTEGRITY;
+ script.crossOrigin = 'anonymous';
+ script.onload = function () {
+ resolve(window.html2canvas);
+ };
+ script.onerror = function () {
+ html2canvasPromise = null;
+ reject(new Error('Failed to load html2canvas'));
+ };
+ document.head.appendChild(script);
+ });
+ return html2canvasPromise;
+ }
+
+ // --- API calls -----------------------------------------------------------
+ function fetchConfig() {
+ return fetch(API_ROOT + '/config?lang=' + LANG, { headers: { Accept: 'application/json' } })
+ .then(function (res) {
+ if (!res.ok) {
+ throw new Error('config fetch failed');
+ }
+ return res.json();
+ })
+ .then(function (body) {
+ return body.data;
+ });
+ }
+
+ // --- widget UI -------------------------------------------------------------
+ function escapeHtml(str) {
+ var div = document.createElement('div');
+ div.textContent = str == null ? '' : str;
+ return div.innerHTML;
+ }
+
+ // Bottom corners cover an app's own bottom nav/action bar on mobile (the
+ // reported problem), so a Marker.io-style vertical edge tab is offered
+ // as an alternative — pinned to the middle of the left/right viewport
+ // edge instead of a corner, out of the way of bottom chrome.
+ var POSITION_CSS = {
+ 'bottom-right': {
+ btn: 'bottom: 20px; right: 20px; border-radius: 999px; padding: 12px 18px;',
+ panel: 'bottom: 76px; right: 20px;',
+ panelMaxWidth: 'calc(100vw - 40px)',
+ },
+ 'bottom-left': {
+ btn: 'bottom: 20px; left: 20px; border-radius: 999px; padding: 12px 18px;',
+ panel: 'bottom: 76px; left: 20px;',
+ panelMaxWidth: 'calc(100vw - 40px)',
+ },
+ // The panel intentionally has NO `top`/`transform` here for vertical
+ // centering — Chromium mispositions a native