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
This commit is contained in:
Jakub Zych
2026-10-04 00:22:08 +02:00
parent 92e1b4e09c
commit af5d77c997
19 changed files with 2285 additions and 6 deletions

View File

@@ -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 <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
@@ -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
<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); `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).

82
admin.go Normal file
View File

@@ -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{} },
}}
}

35
assets.go Normal file
View File

@@ -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))
}
}

643
assets/js/embed.js Normal file
View File

@@ -0,0 +1,643 @@
/*!
* Golem15.Feedback embed widget.
*
* One script tag, self-configuring from its own <script data-key="wk_..."> tag:
* <script src="https://<backend-domain>/plugins/golem15/feedback/assets/js/embed.js"
* data-key="wk_xxx" async></script>
*
* 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 <select> popup (our
// Type dropdown lives inside this panel) when any ancestor has a CSS
// transform, so centering is done in JS via plain `top` in pixels
// instead. See positionPanelVertically().
'middle-right': {
btn: 'top: 50%; right: 0; transform: translateY(-50%) rotate(180deg); writing-mode: vertical-rl;' +
' border-radius: 10px 0 0 10px; padding: 16px 10px;',
panel: 'right: 64px;',
panelMaxWidth: 'calc(100vw - 84px)',
centerPanelVertically: true,
},
'middle-left': {
btn: 'top: 50%; left: 0; transform: translateY(-50%); writing-mode: vertical-rl;' +
' border-radius: 0 10px 10px 0; padding: 16px 10px;',
panel: 'left: 64px;',
panelMaxWidth: 'calc(100vw - 84px)',
centerPanelVertically: true,
},
};
function buildWidget(config) {
var colors = config.colors || {};
var labels = config.labels || {};
var position = POSITION_CSS.hasOwnProperty(config.position) ? config.position : 'bottom-right';
var posCss = POSITION_CSS[position];
var primary = colors.primary || '#111827';
var accent = colors.accent || '#ffffff';
var host = document.createElement('div');
host.setAttribute('data-g15-feedback-widget', '');
var shadow = host.attachShadow({ mode: 'open' });
var style = document.createElement('style');
style.textContent = [
':host { all: initial; }',
// Every box below is sized/positioned by width+padding together
// (e.g. .g15fb-panel's width/max-width vs. its own 16px padding);
// without border-box, padding adds on top of those budgets and
// the panel can render wider than intended, pushing it off-screen.
'.g15fb-btn, .g15fb-panel, .g15fb-panel * { box-sizing: border-box; }',
'.g15fb-btn { position: fixed; ' + posCss.btn + ' z-index: 2147483000;' +
' background: ' + primary + '; color: ' + accent + '; border: none;' +
' font: 600 14px/1.2 system-ui, -apple-system, sans-serif; cursor: pointer; white-space: nowrap;' +
' box-shadow: 0 2px 10px rgba(0,0,0,.25); }',
'.g15fb-panel { position: fixed; ' + posCss.panel + ' z-index: 2147483000; width: 320px;' +
' max-width: ' + posCss.panelMaxWidth + '; background: #fff; color: #111827; border-radius: 12px;' +
' box-shadow: 0 10px 40px rgba(0,0,0,.3); padding: 16px; font: 14px/1.4 system-ui, -apple-system, sans-serif;' +
' display: none; }',
'.g15fb-panel.g15fb-open { display: block; }',
'.g15fb-panel h3 { margin: 0 0 8px; font-size: 15px; }',
'.g15fb-panel textarea, .g15fb-panel input[type=email] {' +
' width: 100%; box-sizing: border-box; border: 1px solid #d1d5db; border-radius: 8px;' +
' padding: 8px; font: inherit; margin-bottom: 8px; background: #fff; color: #111827; }',
'.g15fb-panel textarea { min-height: 80px; resize: vertical; }',
'.g15fb-panel label { display: block; font-size: 12px; color: #6b7280; margin-bottom: 4px; }',
// A plain-content dropdown, not a native <select> — a native
// popup's on-screen position is computed by the browser itself
// and is known to end up in the wrong place under perfectly
// normal conditions (a transformed ancestor, devtools' device
// emulation scaling, etc.), none of which this widget controls.
// This dropdown is just ordinary DOM content positioned relative
// to its own wrapper, so none of that applies.
'.g15fb-type-wrap { position: relative; margin-bottom: 8px; }',
'.g15fb-type-btn { width: 100%; text-align: left; display: flex; justify-content: space-between;' +
' align-items: center; border: 1px solid #d1d5db; border-radius: 8px; padding: 8px;' +
' font: inherit; background: #fff; color: #111827; cursor: pointer; }',
'.g15fb-type-btn::after { content: "\\25BE"; opacity: .6; margin-left: 8px; }',
'.g15fb-type-list { display: none; position: absolute; top: calc(100% + 4px); left: 0; right: 0;' +
' background: #fff; border: 1px solid #d1d5db; border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.15);' +
' z-index: 1; overflow: hidden; }',
'.g15fb-type-list.g15fb-open { display: block; }',
'.g15fb-type-opt { padding: 8px 10px; cursor: pointer; }',
'.g15fb-type-opt:hover, .g15fb-type-opt.g15fb-active { background: #f3f4f6; }',
'.g15fb-preview { display: none; width: 100%; max-height: 120px; object-fit: cover; border-radius: 8px;' +
' margin-bottom: 8px; border: 1px solid #e5e7eb; }',
'.g15fb-row { display: flex; gap: 8px; align-items: center; }',
'.g15fb-submit { background: ' + primary + '; color: ' + accent + '; border: none; border-radius: 8px;' +
' padding: 8px 14px; font: inherit; cursor: pointer; }',
'.g15fb-submit:disabled { opacity: .6; cursor: default; }',
'.g15fb-cancel { background: transparent; border: none; color: #6b7280; cursor: pointer; font: inherit; }',
'.g15fb-status { font-size: 12px; color: #6b7280; margin-top: 6px; min-height: 14px; }',
'.g15fb-hide { display: block; margin-top: 10px; border: none; background: none; padding: 0;' +
' font: inherit; font-size: 12px; color: #9ca3af; cursor: pointer; text-decoration: underline; }',
'.g15fb-hide:hover { color: #6b7280; }',
].join('\n');
var button = document.createElement('button');
button.type = 'button';
button.className = 'g15fb-btn';
button.textContent = labels.title || t('title');
var panel = document.createElement('div');
panel.className = 'g15fb-panel';
panel.innerHTML =
'<h3>' + escapeHtml(labels.title || t('title')) + '</h3>' +
'<img class="g15fb-preview" alt="Screenshot preview" />' +
'<label class="g15fb-type-label">' + escapeHtml(t('typeLabel')) + '</label>' +
'<div class="g15fb-type-wrap">' +
'<button type="button" class="g15fb-type-btn"></button>' +
'<div class="g15fb-type-list" role="listbox">' +
'<div class="g15fb-type-opt" data-value="bug" role="option">' + escapeHtml(t('typeBug')) + '</div>' +
'<div class="g15fb-type-opt" data-value="feature" role="option">' + escapeHtml(t('typeFeature')) + '</div>' +
'<div class="g15fb-type-opt" data-value="other" role="option">' + escapeHtml(t('typeOther')) + '</div>' +
'</div>' +
'</div>' +
'<textarea placeholder="' + escapeHtml(labels.placeholder || t('placeholder')) + '"></textarea>' +
'<input type="email" placeholder="' + escapeHtml(t('emailPlaceholder')) + '" />' +
'<div class="g15fb-row">' +
'<button type="button" class="g15fb-submit">' + escapeHtml(t('send')) + '</button>' +
'<button type="button" class="g15fb-cancel">' + escapeHtml(t('cancel')) + '</button>' +
'</div>' +
'<div class="g15fb-status" role="status"></div>' +
(config.allowHide
? '<button type="button" class="g15fb-hide">' + escapeHtml(t('hideWidget')) + '</button>'
: '');
shadow.appendChild(style);
shadow.appendChild(button);
shadow.appendChild(panel);
document.body.appendChild(host);
var textarea = panel.querySelector('textarea');
var emailInput = panel.querySelector('input[type=email]');
var submitBtn = panel.querySelector('.g15fb-submit');
var cancelBtn = panel.querySelector('.g15fb-cancel');
var statusEl = panel.querySelector('.g15fb-status');
var previewImg = panel.querySelector('.g15fb-preview');
var hideBtn = panel.querySelector('.g15fb-hide');
if (hideBtn) {
hideBtn.addEventListener('click', function () {
// Hides immediately — this file has no token/network access
// of its own, so persistence and the confirmation message
// are entirely the host page's job (it already holds a
// valid auth token and its own toast library).
hideHost();
window.dispatchEvent(new CustomEvent('g15feedback:hide-requested'));
});
}
var typeValue = 'bug';
var typeBtn = panel.querySelector('.g15fb-type-btn');
var typeList = panel.querySelector('.g15fb-type-list');
var typeOpts = panel.querySelectorAll('.g15fb-type-opt');
function setType(value) {
typeValue = value;
for (var i = 0; i < typeOpts.length; i++) {
var opt = typeOpts[i];
var match = opt.getAttribute('data-value') === value;
opt.classList.toggle('g15fb-active', match);
if (match) {
typeBtn.textContent = opt.textContent;
}
}
}
setType(typeValue);
typeBtn.addEventListener('click', function (e) {
e.stopPropagation();
typeList.classList.toggle('g15fb-open');
});
for (var t15 = 0; t15 < typeOpts.length; t15++) {
typeOpts[t15].addEventListener('click', function (e) {
setType(e.currentTarget.getAttribute('data-value'));
typeList.classList.remove('g15fb-open');
});
}
// Closes the dropdown on any click elsewhere in the widget (typing
// in the textarea, hitting Send, etc.) — clicks outside the widget
// entirely don't reach this listener, which is fine: the button
// itself never blocks interacting with the host page underneath it.
shadow.addEventListener('click', function (e) {
if (e.target !== typeBtn && !typeList.contains(e.target)) {
typeList.classList.remove('g15fb-open');
}
});
var screenshotBlob = null;
// Tracks the in-flight capture so submit can wait for it to settle
// instead of racing it — capture takes a beat (html2canvas load +
// render), and a visitor who types fast and hits Send immediately
// would otherwise submit before screenshotBlob is ever set, silently
// sending no screenshot at all. Always resolves (never rejects) so
// .then() alone is enough to wait on it.
var screenshotCapture = Promise.resolve();
// Vertical centering for the middle-left/middle-right positions is
// done here in plain pixels rather than CSS `transform:
// translateY(-50%)` — Chromium mispositions a native <select> popup
// (the Type dropdown below) when any ancestor has a CSS transform,
// so the panel can carry no transform of its own while open.
function positionPanelVertically() {
if (!posCss.centerPanelVertically) {
return;
}
var margin = 12;
var top = (window.innerHeight / 2) - (panel.offsetHeight / 2);
top = Math.max(margin, Math.min(top, window.innerHeight - panel.offsetHeight - margin));
panel.style.top = top + 'px';
}
window.addEventListener('resize', function () {
if (panel.classList.contains('g15fb-open')) {
positionPanelVertically();
}
});
function openPanel() {
panel.classList.add('g15fb-open');
positionPanelVertically();
statusEl.textContent = t('capturing');
screenshotBlob = null;
previewImg.style.display = 'none';
screenshotCapture = loadHtml2Canvas()
.then(function (html2canvas) {
// Our own widget host is a shadow-DOM element already sitting
// in document.body by the time this runs (it's how the button
// that just got clicked exists at all) — no reason to capture
// our own floating button/panel inside the visitor's report.
return html2canvas(document.body, {
logging: false,
useCORS: true,
ignoreElements: function (element) {
return element === host;
},
});
})
.then(function (canvas) {
return new Promise(function (resolve) {
canvas.toBlob(function (blob) {
resolve(blob);
}, 'image/png', 0.85);
});
})
.then(function (blob) {
screenshotBlob = blob;
if (blob) {
previewImg.src = URL.createObjectURL(blob);
previewImg.style.display = 'block';
// The preview adds height to the panel — recenter now
// that it actually changed, not just at open time.
positionPanelVertically();
}
statusEl.textContent = '';
})
.catch(function (err) {
// Screenshot capture is best-effort — a failure (e.g. a tainted
// canvas from cross-origin images) must never block sending
// the text feedback itself. Still surfaced to devtools so a
// silent failure mode isn't invisible to whoever's debugging.
console.warn('[feedback-widget] screenshot capture failed', err);
statusEl.textContent = '';
});
}
function closePanel() {
panel.classList.remove('g15fb-open');
}
button.addEventListener('click', function () {
if (panel.classList.contains('g15fb-open')) {
closePanel();
} else {
openPanel();
}
});
cancelBtn.addEventListener('click', closePanel);
submitBtn.addEventListener('click', function () {
var message = textarea.value.trim();
if (!message) {
statusEl.textContent = t('pleaseDescribe');
return;
}
submitBtn.disabled = true;
statusEl.textContent = t('sending');
// Wait for any in-flight screenshot capture to settle before
// building the request — see screenshotCapture's declaration.
screenshotCapture.then(function () {
var form = new FormData();
form.append('message', message);
form.append('type', typeValue);
if (emailInput.value.trim()) {
form.append('email', emailInput.value.trim());
}
form.append('page_url', location.href);
form.append('user_agent', navigator.userAgent);
form.append('console_log', consoleBuffer.join('\n'));
if (screenshotBlob) {
form.append('screenshot', screenshotBlob, 'screenshot.png');
}
return fetch(API_ROOT + '/submit', { method: 'POST', body: form });
})
.then(function (res) {
if (!res.ok) {
throw new Error('submit failed');
}
return res.json();
})
.then(function () {
statusEl.textContent = labels.success || t('success');
textarea.value = '';
emailInput.value = '';
setTimeout(closePanel, 1500);
})
.catch(function () {
statusEl.textContent = t('error');
})
.finally(function () {
submitBtn.disabled = false;
});
});
// Updates every static string in place after a locale switch —
// called by window.G15Feedback.setLocale(), never rebuilds the DOM.
// t() already reads the (by-then-updated) outer LANG variable, so
// only labels — the admin-configured, already-locale-resolved title/
// placeholder/success strings from /config — need to be passed in.
function refreshLocale(newLabels) {
labels = newLabels || {};
button.textContent = labels.title || t('title');
panel.querySelector('h3').textContent = labels.title || t('title');
panel.querySelector('.g15fb-type-label').textContent = t('typeLabel');
textarea.placeholder = labels.placeholder || t('placeholder');
emailInput.placeholder = t('emailPlaceholder');
submitBtn.textContent = t('send');
cancelBtn.textContent = t('cancel');
statusEl.textContent = '';
for (var i = 0; i < typeOpts.length; i++) {
var value = typeOpts[i].getAttribute('data-value');
typeOpts[i].textContent = t(TYPE_KEY_MAP[value]);
}
setType(typeValue);
if (hideBtn) {
hideBtn.textContent = t('hideWidget');
}
}
// hide()/show() toggle the ENTIRE widget (button + panel) — host is
// the single shadow-DOM root both live under. Exposed on the
// returned instance so window.G15Feedback.hide()/.show() (the host
// page's sync mechanism for the persisted preference) can drive it.
function hideHost() {
host.style.display = 'none';
}
function showHost() {
host.style.display = '';
}
return { refreshLocale: refreshLocale, hide: hideHost, show: showHost };
}
var activeWidget = null;
// Re-fetches /config for the current LANG and applies it to the already-
// built widget in place. A no-op until the widget exists — the initial
// build already renders in whatever LANG is current at that point.
function refreshFromNetwork() {
if (!activeWidget) {
return;
}
fetchConfig()
.then(function (config) {
if (config && activeWidget) {
activeWidget.refreshLocale(config.labels || {});
}
})
.catch(function () {
// Best-effort — the widget just keeps showing the previous locale.
});
}
// Public API for the host page to sync the widget to an in-app locale
// switch (e.g. Nuxt i18n) without a full reload. data-lang only covers
// the locale at page-load time.
window.G15Feedback = window.G15Feedback || {};
window.G15Feedback.setLocale = function (lang) {
var normalized = lang === 'en' ? 'en' : 'pl';
if (normalized === LANG) {
return;
}
LANG = normalized;
refreshFromNetwork();
};
// The host page is the ONLY caller of these — it owns the persisted
// preference (via its own authenticated request) and just tells the
// widget to reflect the result. This file never persists anything itself.
window.G15Feedback.hide = function () {
pendingHidden = true;
if (activeWidget) {
activeWidget.hide();
}
};
window.G15Feedback.show = function () {
pendingHidden = false;
if (activeWidget) {
activeWidget.show();
}
};
function init() {
var fetchedForLang = LANG;
fetchConfig()
.then(function (config) {
if (!config) {
return;
}
activeWidget = buildWidget(config);
if (pendingHidden) {
activeWidget.hide();
}
// setLocale() may have run while this request was already in
// flight — its labels reflect fetchedForLang, not the
// now-current LANG. One corrective refetch closes that race.
if (LANG !== fetchedForLang) {
refreshFromNetwork();
}
})
.catch(function () {
// Widget disabled, unknown key, or this origin isn't allow-listed —
// stay completely silent rather than surface an error on the host page.
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -2,16 +2,36 @@ package classes_test
import (
"bytes"
"context"
"encoding/hex"
"encoding/json"
"errors"
"image"
"image/color"
"image/gif"
"image/jpeg"
"image/png"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
// The plugin registers itself; pgtest activates it.
_ "git.golem15.com/golem15/sm-feedback-plugin"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"git.golem15.com/golem15/summercms/modules/tide"
"gocloud.dev/blob"
_ "gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
func mustHex(t *testing.T, s string) []byte {
@@ -76,3 +96,278 @@ func TestImageGuardCopy(t *testing.T) {
}
}
}
// roundTrip adapts a func to http.RoundTripper.
type roundTrip func(*http.Request) (*http.Response, error)
func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonAnswer(status int, body string) roundTrip {
return func(r *http.Request) (*http.Response, error) {
if r.Body != nil {
_, _ = io.Copy(io.Discard, r.Body)
}
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": {"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
Request: r,
}, nil
}
}
// syncEnv is an app over the shared test database with a memory uploads
// bucket, for StoreSubmission and SyncG15Office.
func syncEnv(t *testing.T) (*backpack.App, *gorm.DB, *blob.Bucket) {
t.Helper()
gdb := pgtest.DB(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("name: feedback-classes\n"), 0o644); err != nil {
t.Fatal(err)
}
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{"SUMMER_ENV=development"}})
if err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
if err := lagoon.Publish(app, pgtest.SQL(t), gdb); err != nil {
t.Fatal(err)
}
bucket, err := blob.OpenBucket(t.Context(), "mem://")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = bucket.Close() })
return app, gdb, bucket
}
// recordedSubmission stores the submission PHP filed when the sidecar was
// recorded (the same fields and screenshot) and returns it.
func recordedSubmission(t *testing.T, app *backpack.App, gdb *gorm.DB, bucket *blob.Bucket) *models.Submission {
t.Helper()
str := func(s string) *string { return &s }
sub := &models.Submission{
Message: "Okładka albumu\tjest zła.\n\nDruga linia z dłuższym opisem problemu, który na pewno przekracza osiemdziesiąt znaków tytułu zadania.",
Type: "feature",
Email: str("reporter@parity.test"),
PageURL: "https://app.parity.test/albums/7?tab=photos",
UserAgent: str("Mozilla/5.0 (X11; Linux x86_64) Parity/1.0"),
ConsoleLog: str("[log] " + strings.Repeat("żółć ąę ", 520)),
}
shot := mustHex(t, "89504e470d0a1a0a0000000d49484452000000080000000808020000004b6d29dc0000001949444154789c626160a8906360c0442c20021b189c128001005509023d9970f43c0000000049454e44ae426082")
if err := classes.StoreSubmission(t.Context(), app, gdb, bucket, sub, &classes.Screenshot{Filename: "screenshot.png", Data: shot}); err != nil {
t.Fatal(err)
}
return sub
}
func reload(t *testing.T, gdb *gorm.DB, id uint) models.Submission {
t.Helper()
var s models.Submission
if err := gdb.Take(&s, id).Error; err != nil {
t.Fatal(err)
}
return s
}
func defaultSettings(t *testing.T, gdb *gorm.DB) {
t.Helper()
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
// Background: Cleanup runs it after the test's context is cancelled.
if _, err := models.Instance(context.Background(), gdb); err != nil {
t.Fatal(err)
}
}
const parityToken = "parityG15OfficeTokenNotReal01"
func parityClient() *classes.G15OfficeClient {
return classes.NewG15OfficeClientWith("https://office.parity.test/", parityToken, "parity-project", nil)
}
// TestSyncG15Office replays the G15Office exchange PHP's
// SyncFeedbackToG15Office made (testdata/g15office-task.upstream.yaml,
// recorded through the upstream proxy): the task JSON, the Bearer token,
// Accept: application/json, no User-Agent, the screenshot as the multipart
// part "file" with its name, type and sha256. The submission ends as PHP's
// did (sent, task tskParity01). The failure paths store status failed and
// PHP's message and return the error so the job is retried; an unconfigured
// client sends nothing.
func TestSyncG15Office(t *testing.T) {
app, gdb, bucket := syncEnv(t)
defaultSettings(t, gdb)
t.Run("php-recorded", func(t *testing.T) {
sub := recordedSubmission(t, app, gdb, bucket)
sidecar, err := tide.LoadUpstream(filepath.Join("testdata", "g15office-task.upstream.yaml"))
if err != nil {
t.Fatal(err)
}
store, err := tide.OpenStore("")
if err != nil {
t.Fatal(err)
}
store.Set("secret:g15office-token", parityToken)
fake := tide.NewUpstreamFake(sidecar, store)
ctx := fetchguard.WithTransport(t.Context(), fake)
if err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
t.Fatal(err)
}
if err := fake.Verify(); err != nil {
t.Fatal(err)
}
got := reload(t, gdb, sub.ID)
if got.Status != models.StatusSent || got.G15OfficeTaskID == nil || *got.G15OfficeTaskID != "tskParity01" || got.G15OfficeError != nil {
t.Fatalf("submission %+v", got)
}
})
failing := func(t *testing.T, client *classes.G15OfficeClient, rt http.RoundTripper, wantErr string) {
t.Helper()
sub := recordedSubmission(t, app, gdb, bucket)
ctx := t.Context()
if rt != nil {
ctx = fetchguard.WithTransport(ctx, rt)
}
err := classes.SyncG15Office(ctx, classes.SyncDeps{DB: gdb, Bucket: bucket, Client: client}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID})
if err == nil || err.Error() != wantErr {
t.Fatalf("error %v, want %q", err, wantErr)
}
got := reload(t, gdb, sub.ID)
if got.Status != models.StatusFailed || got.G15OfficeError == nil || *got.G15OfficeError != wantErr || got.G15OfficeTaskID != nil {
t.Fatalf("submission %+v", got)
}
}
never := roundTrip(func(r *http.Request) (*http.Response, error) {
t.Errorf("unexpected request to %s", r.URL)
return nil, errors.New("no request expected")
})
t.Run("unconfigured-sends-nothing", func(t *testing.T) {
for _, c := range []*classes.G15OfficeClient{
classes.NewG15OfficeClient(nil),
classes.NewG15OfficeClientWith("https://office.parity.test", "", "parity-project", nil),
classes.NewG15OfficeClientWith("https://office.parity.test", parityToken, "", nil),
classes.NewG15OfficeClientWith("", parityToken, "parity-project", nil),
} {
if c.IsConfigured() {
t.Fatal("a partial configuration counts as configured")
}
failing(t, c, never, "g15office is not configured (G15_OFFICE_BASE_URL / G15_OFFICE_TOKEN / G15_OFFICE_PROJECT).")
}
})
t.Run("error-envelope", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(404, `{"error":true,"message":"Project not found"}`), "g15office create task failed: Project not found")
failing(t, parityClient(), jsonAnswer(500, `{"error":1}`), "g15office create task failed: unknown error")
})
t.Run("missing-task-id", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(201, `{"success":true,"data":{"number":7}}`), "g15office did not return a task id.")
failing(t, parityClient(), jsonAnswer(201, `{"data":{"id":""}}`), "g15office did not return a task id.")
})
t.Run("invalid-body", func(t *testing.T) {
failing(t, parityClient(), jsonAnswer(502, `<html>Bad gateway</html>`), "g15office create task failed: invalid response body.")
failing(t, parityClient(), jsonAnswer(200, `"ok"`), "g15office create task failed: invalid response body.")
})
t.Run("connection-error", func(t *testing.T) {
failing(t, parityClient(), roundTrip(func(*http.Request) (*http.Response, error) { return nil, errors.New("dial failed") }), "g15office create task failed: connection error.")
})
t.Run("attach-failure", func(t *testing.T) {
calls := 0
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
calls++
if strings.HasSuffix(r.URL.Path, "/tasks") {
return jsonAnswer(201, `{"data":{"id":"tskX"}}`)(r)
}
return jsonAnswer(413, `{"error":true,"message":"File too large"}`)(r)
})
failing(t, parityClient(), rt, "g15office attach file failed: File too large")
if calls != 2 {
t.Fatalf("%d requests", calls)
}
})
t.Run("http-base-url-is-refused", func(t *testing.T) {
failing(t, classes.NewG15OfficeClientWith("http://office.parity.test", parityToken, "parity-project", nil), never, "g15office create task failed: connection error.")
})
t.Run("missing-submission", func(t *testing.T) {
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), never), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: 999999999}); err != nil {
t.Fatal(err)
}
})
t.Run("settings-status-and-priority", func(t *testing.T) {
status, priority := "Nowe", "urgent"
if err := gdb.Model(&models.Settings{}).Where("id = ?", models.SettingsID).Updates(map[string]any{
"g15office_task_status": status, "g15office_task_priority": priority,
}).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { defaultSettings(t, gdb) })
var body map[string]any
rt := roundTrip(func(r *http.Request) (*http.Response, error) {
if strings.HasSuffix(r.URL.Path, "/tasks") {
raw, _ := io.ReadAll(r.Body)
if err := json.Unmarshal(raw, &body); err != nil {
t.Errorf("task body %s", raw)
}
return jsonAnswer(201, `{"data":{"id":"tskY"}}`)(r)
}
return jsonAnswer(201, `{"data":{"id":"att"}}`)(r)
})
sub := recordedSubmission(t, app, gdb, bucket)
if err := classes.SyncG15Office(fetchguard.WithTransport(t.Context(), rt), classes.SyncDeps{DB: gdb, Bucket: bucket, Client: parityClient()}, classes.SyncG15OfficeArgs{SubmissionID: sub.ID}); err != nil {
t.Fatal(err)
}
if body["status"] != status || body["priority"] != priority || body["type"] != "Feature" {
t.Fatalf("task body %v", body)
}
})
}
// TestTaskText pins buildTitle and buildDescription: whitespace collapsed
// as PCRE's \s (vertical tab included, no-break space not), Str::limit by
// display width (wide characters count two columns), and the optional
// metadata lines skipped for "" and "0" as PHP's truthiness does.
func TestTaskText(t *testing.T) {
limits := []struct{ in, want string }{
{"abc", "abc"},
{strings.Repeat("a", 80), strings.Repeat("a", 80)},
{strings.Repeat("a", 81), strings.Repeat("a", 80) + "..."},
{strings.Repeat("中", 41), strings.Repeat("中", 40) + "..."},
{"a" + strings.Repeat("中", 40), "a" + strings.Repeat("中", 39) + "..."},
{strings.Repeat("a", 79) + " b", strings.Repeat("a", 79) + "..."},
{strings.Repeat("ą", 81), strings.Repeat("ą", 80) + "..."},
}
for _, c := range limits {
if got := classes.StrLimit(c.in, 80, "..."); got != c.want {
t.Errorf("StrLimit(%q) = %q", c.in, got)
}
}
if got := classes.TaskTitle(" a\x0b\x0c b c \n"); got != "Feedback: a b c" {
t.Errorf("title %q", got)
}
if w := classes.StrWidth("ą中😀アA"); w != 1+2+2+1+2 {
t.Errorf("width %d", w)
}
zero, empty := "0", ""
sub := &models.Submission{Message: "Hi", PageURL: "https://example.test/", Email: &zero, UserAgent: &empty, ConsoleLog: &zero}
if got := classes.TaskDescription(sub); got != "Hi\n\n---\n\n- **Page:** https://example.test/" {
t.Errorf("description %q", got)
}
email, ua, log := "a@example.test", "UA", "line1\nline2"
sub = &models.Submission{Message: "Hi", PageURL: "/p", Email: &email, UserAgent: &ua, ConsoleLog: &log}
want := "Hi\n\n---\n\n- **Page:** /p\n- **Reporter email:** a@example.test\n- **User agent:** UA\n\n**Console log:**\n```\nline1\nline2\n```"
if got := classes.TaskDescription(sub); got != want {
t.Errorf("description %q", got)
}
long := strings.Repeat("x", 4001)
sub.ConsoleLog = &long
if got := classes.TaskDescription(sub); !strings.HasSuffix(got, strings.Repeat("x", 4000)+"… (truncated)\n```") {
t.Errorf("console log not cut at 4000 columns")
}
for typ, want := range map[string]string{"bug": "Bug", "feature": "Feature", "other": "Task", "x": "Task"} {
if got := (models.Submission{Type: typ}).G15OfficeTaskType(); got != want {
t.Errorf("type %s -> %s", typ, got)
}
}
}

247
classes/g15office_client.go Normal file
View File

@@ -0,0 +1,247 @@
package classes
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/fetchguard"
)
// Config keys of the G15Office connection (config/feedback.php
// g15_office.*, G15_OFFICE_* in the WinterCMS plugin).
const (
ConfigBaseURL = "golem15.feedback.g15_office.base_url"
ConfigToken = "golem15.feedback.g15_office.token"
ConfigProject = "golem15.feedback.g15_office.project"
)
// G15OfficeTimeout bounds one G15Office request (the PHP client sets none).
const G15OfficeTimeout = 30 * time.Second
// ErrNotConfigured is G15OfficeClient::assertConfigured's failure; its text
// is PHP's, which the submission's g15office_error column keeps.
var ErrNotConfigured = errors.New("g15office is not configured (G15_OFFICE_BASE_URL / G15_OFFICE_TOKEN / G15_OFFICE_PROJECT).")
// G15OfficeClient is the port of G15OfficeClient: the support portal's task
// API, used to file a task per submission and attach its screenshot. Every
// request goes through a fetchguard.Client in AllowHostsMode limited to the
// base URL's host (https only, private addresses refused at dial time), so
// submission content can reach no other host. It sends what PHP's raw curl
// sent: a Bearer token, Accept: application/json and no User-Agent.
type G15OfficeClient struct {
baseURL string
token string
project string
client *fetchguard.Client
initErr error
}
// NewG15OfficeClient builds the client from the plugin config.
func NewG15OfficeClient(cfg *compass.Config) *G15OfficeClient {
var baseURL, token, project string
if cfg != nil {
baseURL, token, project = cfg.String(ConfigBaseURL), cfg.String(ConfigToken), cfg.String(ConfigProject)
}
return NewG15OfficeClientWith(baseURL, token, project, cfg)
}
// NewG15OfficeClientWith builds the client from explicit values; the base
// URL loses its trailing slashes, as PHP's rtrim does.
func NewG15OfficeClientWith(baseURL, token, project string, cfg *compass.Config) *G15OfficeClient {
c := &G15OfficeClient{
baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"),
token: strings.TrimSpace(token),
project: strings.TrimSpace(project),
}
if !c.IsConfigured() {
return c
}
u, err := url.Parse(c.baseURL)
if err != nil || u.Hostname() == "" {
c.initErr = fmt.Errorf("g15office base URL %q has no host", c.baseURL)
return c
}
c.client, c.initErr = fetchguard.NewClient(fetchguard.Policy{
Mode: fetchguard.AllowHostsMode,
AllowHosts: []string{u.Hostname()},
Timeout: G15OfficeTimeout,
}, cfg)
return c
}
// IsConfigured reports whether the base URL, the token and the project
// are all set; without them nothing is ever sent.
func (c *G15OfficeClient) IsConfigured() bool {
return c != nil && c.baseURL != "" && c.token != "" && c.project != ""
}
// TaskFields is the createTask body, in PHP's key order. Status is left
// out when empty (array_filter).
type TaskFields struct {
Title string `json:"title"`
Description string `json:"description"`
Type string `json:"type"`
Status string `json:"status,omitempty"`
Priority string `json:"priority"`
}
// CreateTask is G15OfficeClient::createTask: POST the task JSON to
// <base>/_support/api/v1/projects/<project>/tasks and return its "data"
// object (with the task's hash id).
func (c *G15OfficeClient) CreateTask(ctx context.Context, fields TaskFields) (map[string]any, error) {
if err := c.ready(); err != nil {
return nil, err
}
var body bytes.Buffer
enc := json.NewEncoder(&body)
enc.SetEscapeHTML(false)
if err := enc.Encode(fields); err != nil {
return nil, err
}
target := c.baseURL + "/_support/api/v1/projects/" + c.project + "/tasks"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(bytes.TrimRight(body.Bytes(), "\n")))
if err != nil {
return nil, fmt.Errorf("g15office create task failed: connection error.")
}
req.Header = c.headers()
req.Header.Set("Content-Type", "application/json")
res, err := c.client.Send(req)
return decodeG15Office(res, err, "create task")
}
// AttachFile is G15OfficeClient::attachFile: POST the file as the
// multipart field "file" to <base>/_support/api/v1/tasks/<hashID>/attachments
// and return the attachment's "data" object.
func (c *G15OfficeClient) AttachFile(ctx context.Context, hashID, name, mimeType string, body io.Reader) (map[string]any, error) {
if err := c.ready(); err != nil {
return nil, err
}
target := c.baseURL + "/_support/api/v1/tasks/" + hashID + "/attachments"
res, err := c.client.PostMultipart(ctx, target, c.headers(), nil, []fetchguard.FormFile{{
Field: "file", Filename: name, ContentType: mimeType, Body: body,
}})
return decodeG15Office(res, err, "attach file")
}
func (c *G15OfficeClient) ready() error {
if !c.IsConfigured() {
return ErrNotConfigured
}
return c.initErr
}
// headers are PHP's: the Bearer token and Accept: application/json, with an
// empty User-Agent so net/http adds none (raw curl sends none).
func (c *G15OfficeClient) headers() http.Header {
return http.Header{
"Authorization": {fetchguard.Bearer(c.token)},
"Accept": {"application/json"},
"User-Agent": {""},
}
}
// decodeG15Office is G15OfficeClient::decode: PHP reads no status code, only
// the body. A failed connection, a body that is not a JSON object or list,
// or a truthy "error" key fail with PHP's messages; otherwise the "data"
// value is returned (the whole body when it has none).
func decodeG15Office(res *fetchguard.Result, err error, action string) (map[string]any, error) {
if err != nil || res == nil {
return nil, fmt.Errorf("g15office %s failed: connection error.", action)
}
var decoded any
dec := json.NewDecoder(bytes.NewReader(res.Body))
dec.UseNumber()
if err := dec.Decode(&decoded); err != nil {
return nil, fmt.Errorf("g15office %s failed: invalid response body.", action)
}
switch v := decoded.(type) {
case map[string]any:
if phpTruthy(v["error"]) {
msg := "unknown error"
if m, ok := v["message"]; ok && m != nil {
msg = phpString(m)
}
return nil, fmt.Errorf("g15office %s failed: %s", action, msg)
}
if data, ok := v["data"].(map[string]any); ok {
return data, nil
}
if d, ok := v["data"]; ok && d != nil {
return map[string]any{}, nil
}
return v, nil
case []any:
return map[string]any{}, nil
}
return nil, fmt.Errorf("g15office %s failed: invalid response body.", action)
}
// phpTruthy is PHP's !empty() of a decoded JSON value.
func phpTruthy(v any) bool {
switch t := v.(type) {
case nil:
return false
case bool:
return t
case string:
return t != "" && t != "0"
case json.Number:
f, err := t.Float64()
return err != nil || f != 0
case []any:
return len(t) > 0
case map[string]any:
return len(t) > 0
}
return true
}
// phpString is PHP's string interpolation of a decoded JSON scalar.
func phpString(v any) string {
switch t := v.(type) {
case string:
return t
case json.Number:
return t.String()
case bool:
if t {
return "1"
}
return ""
case nil:
return ""
case []any, map[string]any:
return "Array"
}
return fmt.Sprint(v)
}
// taskID is the task's id as PHP keeps it ($task['id'] ?? null), "" when
// it is missing or falsy, which the job treats as no id.
func taskID(task map[string]any) string {
v, ok := task["id"]
if !ok || !phpTruthy(v) {
return ""
}
switch t := v.(type) {
case json.Number:
return t.String()
case bool:
return "1"
case string:
return t
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
}
return phpString(v)
}

177
classes/str_limit.go Normal file
View File

@@ -0,0 +1,177 @@
package classes
import "strings"
// StrLimit is Laravel's Str::limit: value as it is when its display width
// (mb_strwidth) is at most limit, else the widest prefix that fits
// (mb_strimwidth), right-trimmed, followed by end.
func StrLimit(value string, limit int, end string) string {
if StrWidth(value) <= limit {
return value
}
width := 0
cut := len(value)
for i, r := range value {
w := runeWidth(r)
if width+w > limit {
cut = i
break
}
width += w
}
return strings.TrimRight(value[:cut], " \t\n\r\x00\x0B") + end
}
// StrWidth is mb_strwidth(value, 'UTF-8'): 2 columns for each East Asian
// wide or fullwidth character, 1 for every other one.
func StrWidth(value string) int {
n := 0
for _, r := range value {
n += runeWidth(r)
}
return n
}
func runeWidth(r rune) int {
lo, hi := 0, len(wideRanges)
for lo < hi {
mid := (lo + hi) / 2
switch {
case r < wideRanges[mid][0]:
hi = mid
case r > wideRanges[mid][1]:
lo = mid + 1
default:
return 2
}
}
return 1
}
// wideRanges are the code points PHP's mbstring counts as two columns
// (generated from mb_strwidth over U+0000..U+3FFFF on PHP 8.5).
var wideRanges = [][2]rune{
{0x1100, 0x115F},
{0x231A, 0x231B},
{0x2329, 0x232A},
{0x23E9, 0x23EC},
{0x23F0, 0x23F0},
{0x23F3, 0x23F3},
{0x25FD, 0x25FE},
{0x2614, 0x2615},
{0x2630, 0x2637},
{0x2648, 0x2653},
{0x267F, 0x267F},
{0x268A, 0x268F},
{0x2693, 0x2693},
{0x26A1, 0x26A1},
{0x26AA, 0x26AB},
{0x26BD, 0x26BE},
{0x26C4, 0x26C5},
{0x26CE, 0x26CE},
{0x26D4, 0x26D4},
{0x26EA, 0x26EA},
{0x26F2, 0x26F3},
{0x26F5, 0x26F5},
{0x26FA, 0x26FA},
{0x26FD, 0x26FD},
{0x2705, 0x2705},
{0x270A, 0x270B},
{0x2728, 0x2728},
{0x274C, 0x274C},
{0x274E, 0x274E},
{0x2753, 0x2755},
{0x2757, 0x2757},
{0x2795, 0x2797},
{0x27B0, 0x27B0},
{0x27BF, 0x27BF},
{0x2B1B, 0x2B1C},
{0x2B50, 0x2B50},
{0x2B55, 0x2B55},
{0x2E80, 0x2E99},
{0x2E9B, 0x2EF3},
{0x2F00, 0x2FD5},
{0x2FF0, 0x303E},
{0x3041, 0x3096},
{0x3099, 0x30FF},
{0x3105, 0x312F},
{0x3131, 0x318E},
{0x3190, 0x31E5},
{0x31EF, 0x321E},
{0x3220, 0x3247},
{0x3250, 0xA48C},
{0xA490, 0xA4C6},
{0xA960, 0xA97C},
{0xAC00, 0xD7A3},
{0xF900, 0xFAFF},
{0xFE10, 0xFE19},
{0xFE30, 0xFE52},
{0xFE54, 0xFE66},
{0xFE68, 0xFE6B},
{0xFF01, 0xFF60},
{0xFFE0, 0xFFE6},
{0x16FE0, 0x16FE4},
{0x16FF0, 0x16FF6},
{0x17000, 0x18CD5},
{0x18CFF, 0x18D1E},
{0x18D80, 0x18DF2},
{0x1AFF0, 0x1AFF3},
{0x1AFF5, 0x1AFFB},
{0x1AFFD, 0x1AFFE},
{0x1B000, 0x1B122},
{0x1B132, 0x1B132},
{0x1B150, 0x1B152},
{0x1B155, 0x1B155},
{0x1B164, 0x1B167},
{0x1B170, 0x1B2FB},
{0x1D300, 0x1D356},
{0x1D360, 0x1D376},
{0x1F004, 0x1F004},
{0x1F0CF, 0x1F0CF},
{0x1F18E, 0x1F18E},
{0x1F191, 0x1F19A},
{0x1F200, 0x1F202},
{0x1F210, 0x1F23B},
{0x1F240, 0x1F248},
{0x1F250, 0x1F251},
{0x1F260, 0x1F265},
{0x1F300, 0x1F320},
{0x1F32D, 0x1F335},
{0x1F337, 0x1F37C},
{0x1F37E, 0x1F393},
{0x1F3A0, 0x1F3CA},
{0x1F3CF, 0x1F3D3},
{0x1F3E0, 0x1F3F0},
{0x1F3F4, 0x1F3F4},
{0x1F3F8, 0x1F43E},
{0x1F440, 0x1F440},
{0x1F442, 0x1F4FC},
{0x1F4FF, 0x1F53D},
{0x1F54B, 0x1F54E},
{0x1F550, 0x1F567},
{0x1F57A, 0x1F57A},
{0x1F595, 0x1F596},
{0x1F5A4, 0x1F5A4},
{0x1F5FB, 0x1F64F},
{0x1F680, 0x1F6C5},
{0x1F6CC, 0x1F6CC},
{0x1F6D0, 0x1F6D2},
{0x1F6D5, 0x1F6D8},
{0x1F6DC, 0x1F6DF},
{0x1F6EB, 0x1F6EC},
{0x1F6F4, 0x1F6FC},
{0x1F7E0, 0x1F7EB},
{0x1F7F0, 0x1F7F0},
{0x1F90C, 0x1F93A},
{0x1F93C, 0x1F945},
{0x1F947, 0x1F9FF},
{0x1FA70, 0x1FA7C},
{0x1FA80, 0x1FA8A},
{0x1FA8E, 0x1FAC6},
{0x1FAC8, 0x1FAC8},
{0x1FACD, 0x1FADC},
{0x1FADF, 0x1FAEA},
{0x1FAEF, 0x1FAF8},
{0x20000, 0x2FFFD},
{0x30000, 0x3FFFD},
}

View File

@@ -1,5 +1,18 @@
package classes
import (
"context"
"errors"
"regexp"
"strings"
"time"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
"gocloud.dev/blob"
"gorm.io/gorm"
)
// The G15Office sync job's contract: SubmitFeedback queues one per stored
// submission, the worker files it (SyncFeedbackToG15Office).
const (
@@ -20,3 +33,122 @@ type SyncG15OfficeArgs struct {
// Kind is the River job kind.
func (SyncG15OfficeArgs) Kind() string { return SyncG15OfficeKind }
// SyncDeps are what one run of the job needs, resolved per run.
type SyncDeps struct {
DB *gorm.DB
Bucket *blob.Bucket
Client *G15OfficeClient
}
// SyncG15Office is SyncFeedbackToG15Office::handle: it files the submission
// as a G15Office task (title, Markdown description, type, the settings'
// task status and priority) and attaches its screenshot. Success stores
// status sent and the task id; any failure stores status failed and the
// error and returns the error, so the job is retried. A submission that is
// gone is skipped. An unconfigured client fails before any request, so
// nothing leaves the application.
func SyncG15Office(ctx context.Context, deps SyncDeps, args SyncG15OfficeArgs) error {
if deps.DB == nil {
return errors.New("feedback: database is not configured")
}
db := deps.DB.WithContext(ctx)
var rows []models.Submission
if err := db.Where("id = ?", args.SubmissionID).Limit(1).Find(&rows).Error; err != nil {
return err
}
if len(rows) == 0 {
return nil
}
sub := rows[0]
id, err := fileTask(ctx, deps, &sub)
if err != nil {
msg := err.Error()
if uerr := db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusFailed, "g15office_error": msg, "updated_at": time.Now(),
}).Error; uerr != nil {
return errors.Join(err, uerr)
}
return err
}
return db.Model(&models.Submission{}).Where("id = ?", sub.ID).Updates(map[string]any{
"status": models.StatusSent, "g15office_task_id": id, "g15office_error": nil, "updated_at": time.Now(),
}).Error
}
func fileTask(ctx context.Context, deps SyncDeps, sub *models.Submission) (string, error) {
settings, err := models.Instance(ctx, deps.DB)
if err != nil {
return "", err
}
fields := TaskFields{
Title: TaskTitle(sub.Message),
Description: TaskDescription(sub),
Type: sub.G15OfficeTaskType(),
Priority: "normal",
}
if s := settings.G15OfficeTaskStatus; s != nil && phpTruthy(*s) {
fields.Status = *s
}
if p := settings.G15OfficeTaskPriority; p != nil && phpTruthy(*p) {
fields.Priority = *p
}
task, err := deps.Client.CreateTask(ctx, fields)
if err != nil {
return "", err
}
id := taskID(task)
if id == "" {
return "", errors.New("g15office did not return a task id.")
}
file, err := ScreenshotOf(ctx, deps.DB, sub.ID)
if err != nil {
return "", err
}
if file != nil {
if deps.Bucket == nil {
return "", errors.New("feedback: upload bucket is not configured")
}
r, err := deps.Bucket.NewReader(ctx, attach.BlobKey(file.DiskName), nil)
if err != nil {
return "", err
}
_, err = deps.Client.AttachFile(ctx, id, file.FileName, file.ContentType, r)
_ = r.Close()
if err != nil {
return "", err
}
}
return id, nil
}
// whitespaceRun is PCRE's \s+ without the u flag: ASCII space, tab, LF, VT, FF
// and CR (Go's \s leaves out VT).
var whitespaceRun = regexp.MustCompile(`[\t\n\v\f\r ]+`)
// TaskTitle is SyncFeedbackToG15Office::buildTitle: "Feedback: " and the
// message with whitespace runs collapsed, trimmed and cut to 80 columns.
func TaskTitle(message string) string {
collapsed := strings.Trim(whitespaceRun.ReplaceAllString(message, " "), " \t\n\r\x00\x0B")
return "Feedback: " + StrLimit(collapsed, 80, "...")
}
// TaskDescription is SyncFeedbackToG15Office::buildDescription: the message,
// a rule, one bullet per metadata line (page, then the reporter email and
// the user agent when set) and the console log in a code fence, cut to
// 4000 columns, blocks separated by blank lines.
func TaskDescription(sub *models.Submission) string {
blocks := []string{sub.Message, "---"}
meta := []string{"- **Page:** " + sub.PageURL}
if sub.Email != nil && phpTruthy(*sub.Email) {
meta = append(meta, "- **Reporter email:** "+*sub.Email)
}
if sub.UserAgent != nil && phpTruthy(*sub.UserAgent) {
meta = append(meta, "- **User agent:** "+*sub.UserAgent)
}
blocks = append(blocks, strings.Join(meta, "\n"))
if sub.ConsoleLog != nil && phpTruthy(*sub.ConsoleLog) {
blocks = append(blocks, "**Console log:**\n```\n"+StrLimit(*sub.ConsoleLog, 4000, "… (truncated)")+"\n```")
}
return strings.Join(blocks, "\n\n")
}

File diff suppressed because one or more lines are too long

180
console/import_settings.go Normal file
View File

@@ -0,0 +1,180 @@
// Package console holds the golem15.feedback operator commands.
package console
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/lagoon"
"gorm.io/gorm"
)
// ErrSettingsCustomised refuses an import over settings an operator has
// already changed (the row was saved after it was created).
var ErrSettingsCustomised = errors.New("golem15_feedback_settings was already changed; nothing was imported (pass --force to overwrite it)")
// importNotice is a clean stop: there is nothing to import.
type importNotice struct{ msg string }
func (n *importNotice) Error() string { return n.msg }
// IsNothingToImport reports an import that stopped because the source
// holds nothing (no system_settings table or no golem15_feedback_settings
// row).
func IsNothingToImport(err error) bool {
var n *importNotice
return errors.As(err, &n)
}
// ImportSettings is feedback:import-settings: a one-time copy of the
// WinterCMS widget settings (the system_settings row
// golem15_feedback_settings) into the golem15_feedback_settings singleton,
// including the widget key, so embed snippets keep working. It writes only
// while the singleton still holds what it was created with (or does not
// exist yet); --force overwrites changed settings.
func ImportSettings(app *backpack.App) bonfire.Command {
return bonfire.Command{
Name: "feedback:import-settings",
Description: "Import the WinterCMS golem15_feedback_settings row into golem15_feedback_settings, once.",
Flags: []bonfire.Flag{{
Name: "force",
Description: "Overwrite settings that were already changed",
Bare: true,
}},
Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error {
if app == nil {
return errors.New("feedback:import-settings: app is nil")
}
db, ok := app.Lookup[*gorm.DB]()
if !ok || db == nil {
return errors.New("feedback:import-settings: database is not configured")
}
force, _ := in.Flag("force")
err := Import(ctx, db, force == "true")
var notice *importNotice
switch {
case errors.As(err, &notice):
out.Info(notice.msg)
return nil
case err != nil:
out.Error(err.Error())
return err
}
out.Success("Imported the feedback widget settings.")
return nil
},
}
}
// Import copies the system_settings row into the singleton. Keys the row
// does not hold keep their current (default) values.
func Import(ctx context.Context, db *gorm.DB, force bool) error {
raw, err := settingsValue(ctx, db)
if err != nil {
return err
}
var values map[string]any
dec := json.NewDecoder(strings.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&values); err != nil {
return fmt.Errorf("feedback:import-settings: the settings are not a JSON object: %w", err)
}
return lagoon.Transaction(ctx, db, func(ctx context.Context, tx *gorm.DB) error {
s, err := models.Instance(ctx, tx)
if err != nil {
return err
}
if !force && !s.UpdatedAt.Equal(s.CreatedAt) {
return ErrSettingsCustomised
}
apply(s, values)
s.UpdatedAt = time.Now()
return tx.WithContext(ctx).Save(s).Error
})
}
func settingsValue(ctx context.Context, db *gorm.DB) (string, error) {
if !db.WithContext(ctx).Migrator().HasTable("system_settings") {
return "", &importNotice{msg: "No system_settings table: nothing to import."}
}
var values []string
if err := db.WithContext(ctx).Raw(`SELECT value FROM system_settings WHERE item = ?`, models.SettingsCode).Scan(&values).Error; err != nil {
return "", err
}
if len(values) == 0 {
return "", &importNotice{msg: "No " + models.SettingsCode + " row in system_settings: nothing to import."}
}
return values[0], nil
}
func apply(s *models.Settings, v map[string]any) {
if x, ok := v["enabled"]; ok {
s.Enabled = truthy(x)
}
if x, ok := v["allow_hide"]; ok {
s.AllowHide = truthy(x)
}
if key := str(v["widget_key"]); key != nil && strings.TrimSpace(*key) != "" {
s.WidgetKey = *key
}
for name, field := range map[string]**string{
"position": &s.Position,
"allowed_origins": &s.AllowedOrigins,
"color_primary": &s.ColorPrimary,
"color_accent": &s.ColorAccent,
"label_title_pl": &s.LabelTitlePL,
"label_title_en": &s.LabelTitleEN,
"label_placeholder_pl": &s.LabelPlaceholderPL,
"label_placeholder_en": &s.LabelPlaceholderEN,
"label_success_pl": &s.LabelSuccessPL,
"label_success_en": &s.LabelSuccessEN,
"g15office_task_status": &s.G15OfficeTaskStatus,
"g15office_task_priority": &s.G15OfficeTaskPriority,
} {
if x, ok := v[name]; ok {
*field = str(x)
}
}
}
// str is a stored scalar as a string; null stays nil.
func str(v any) *string {
var s string
switch t := v.(type) {
case nil:
return nil
case string:
s = t
case json.Number:
s = t.String()
case bool:
// PHP's string of a boolean.
if t {
s = "1"
}
default:
return nil
}
return &s
}
// truthy is PHP's (bool) of a stored switch value.
func truthy(v any) bool {
switch t := v.(type) {
case bool:
return t
case string:
return t != "" && t != "0"
case json.Number:
f, err := t.Float64()
return err != nil || f != 0
}
return false
}

View File

@@ -0,0 +1,13 @@
// Package controllers holds the golem15.feedback admin controllers.
package controllers
import "git.golem15.com/golem15/summercms/modules/pact"
// PermissionManageSettings guards the widget settings and the submissions
// list (WinterCMS registers it for the developer role).
const PermissionManageSettings = "golem15.feedback.manage_settings"
// AdminControllers lists the plugin's admin controllers.
func AdminControllers() []pact.AdminController {
return []pact.AdminController{SubmissionsAdminController{}}
}

View File

@@ -0,0 +1,13 @@
list: ~/plugins/golem15/feedback/models/submission/columns.yaml
modelClass: Golem15\Feedback\Models\FeedbackSubmission
title: golem15.feedback::lang.submissions.list_title
noRecordsMessage: backend::lang.list.no_records
recordsPerPage: 20
showCheckboxes: false
defaultSort:
column: created_at
direction: desc
toolbar:
search:
prompt: backend::lang.list.search_prompt

View File

@@ -0,0 +1,36 @@
package controllers
import (
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/summercms/modules/pact"
)
// SubmissionsAdminController is the read-only list of the widget's
// submissions (golem15.feedback.submissions): no form, no create, no
// delete. The WinterCMS plugin had no such screen; operators read reports
// in G15Office, and this list shows what was received and whether it was
// filed (status, task id).
type SubmissionsAdminController struct{}
var (
_ pact.AdminController = SubmissionsAdminController{}
_ pact.AdminPermissioned = SubmissionsAdminController{}
_ pact.AdminRecordSource = SubmissionsAdminController{}
)
// ID is golem15.feedback.submissions.
func (SubmissionsAdminController) ID() string { return "golem15.feedback.submissions" }
// ModelName is the model's WinterCMS class name.
func (SubmissionsAdminController) ModelName() string { return models.Submission{}.MorphName() }
// ConfigDir holds config_list.yaml.
func (SubmissionsAdminController) ConfigDir() string { return "controllers/submissions" }
// RequiredPermissions is golem15.feedback.manage_settings.
func (SubmissionsAdminController) RequiredPermissions() []string {
return []string{PermissionManageSettings}
}
// NewRecord returns an empty submission.
func (SubmissionsAdminController) NewRecord() any { return &models.Submission{} }

View File

@@ -3,9 +3,13 @@ package feedback
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"image"
"image/png"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
@@ -18,12 +22,14 @@ import (
"testing"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/sm-feedback-plugin/console"
"git.golem15.com/golem15/sm-feedback-plugin/controllers/api"
"git.golem15.com/golem15/sm-feedback-plugin/internal/pgtest"
"git.golem15.com/golem15/sm-feedback-plugin/models"
usercontrollers "git.golem15.com/golem15/sm-user-plugin/controllers"
usermodels "git.golem15.com/golem15/sm-user-plugin/models"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/cabana"
"git.golem15.com/golem15/summercms/modules/compass"
"git.golem15.com/golem15/summercms/modules/lagoon"
"git.golem15.com/golem15/summercms/modules/lagoon/attach"
@@ -38,6 +44,10 @@ import (
// testJWTSecret is the test-only golem15.user JWT secret.
const testJWTSecret = "feedback-test-jwt-secret-0123456789abcdef"
// testAdminSecret is the test-only admin JWT secret (the plugin has admin
// controllers, so the admin needs one to boot).
const testAdminSecret = "feedback-test-admin-secret-0123456789abcdef"
// testWidgetKey is the widget key the tests store.
const testWidgetKey = "wk_TestKey0123456789abcdefghijklmnop"
@@ -54,6 +64,7 @@ func testConfig(t testing.TB) *compass.Config {
cfg, err := compass.Open(compass.Options{Dir: dir, Environ: []string{
"SUMMER_ENV=development",
"SUMMER_GOLEM15__USER__JWT__SECRET=" + testJWTSecret,
"SUMMER_ADMIN__JWT__SECRET=" + testAdminSecret,
}})
if err != nil {
t.Fatal(err)
@@ -782,3 +793,162 @@ func TestFeedbackOptionsPreflight(t *testing.T) {
}
}
}
// phpEmbedJSSHA256 is the sha256 of the WinterCMS plugin's
// assets/js/embed.js, which the Go plugin serves byte for byte.
const phpEmbedJSSHA256 = "a76d27dc5debfee5834fb533320be201ff22324a0e845c373dbef8c2dd79bb50"
// TestEmbedJSServed: GET /plugins/golem15/feedback/assets/js/embed.js is
// the PHP file byte for byte, as JavaScript, with conditional requests.
func TestEmbedJSServed(t *testing.T) {
h, _, _ := newHandler(t)
rec := do(t, h, httptest.NewRequest(http.MethodGet, EmbedJSPath, nil))
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
sum := sha256.Sum256(rec.Body.Bytes())
if hex.EncodeToString(sum[:]) != phpEmbedJSSHA256 {
t.Fatal("embed.js differs from the PHP file")
}
if ct := rec.Header().Get("Content-Type"); ct != "application/javascript; charset=utf-8" {
t.Fatalf("Content-Type %q", ct)
}
etag := rec.Header().Get("ETag")
req := httptest.NewRequest(http.MethodGet, EmbedJSPath, nil)
req.Header.Set("If-None-Match", etag)
if rec := do(t, h, req); etag == "" || rec.Code != http.StatusNotModified {
t.Fatalf("conditional GET: %d (ETag %q)", rec.Code, etag)
}
}
// TestFeedbackImportSettings: feedback:import-settings copies the WinterCMS
// row into the singleton once (widget key included), refuses changed
// settings without --force, and reports a missing source cleanly.
func TestFeedbackImportSettings(t *testing.T) {
ctx := context.Background()
gdb := pgtest.DB(t)
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
if err := gdb.Exec(`DROP TABLE IF EXISTS system_settings`).Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { gdb.Exec(`DROP TABLE IF EXISTS system_settings`) })
cmds := (&Plugin{}).Commands()
if len(cmds) != 1 || cmds[0].Name != "feedback:import-settings" || len(cmds[0].Flags) != 1 || cmds[0].Flags[0].Name != "force" {
t.Fatalf("commands %+v", cmds)
}
err := console.Import(ctx, gdb, false)
if !console.IsNothingToImport(err) || err.Error() != "No system_settings table: nothing to import." {
t.Fatalf("missing table: %v", err)
}
if err := gdb.Exec(`CREATE TABLE system_settings (id SERIAL PRIMARY KEY, item TEXT, value TEXT)`).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, false); !console.IsNothingToImport(err) || err.Error() != "No golem15_feedback_settings row in system_settings: nothing to import." {
t.Fatalf("missing row: %v", err)
}
value := `{"enabled":"1","allow_hide":"0","widget_key":"wk_FromWinter0000000000000000000000","position":"middle-left",` +
`"allowed_origins":"https://example.com\r\nexample.org","color_primary":"#111827","color_accent":null,` +
`"label_title_pl":"Napisz do nas","label_title_en":"Write to us","g15office_task_status":"","g15office_task_priority":"high"}`
if err := gdb.Exec(`INSERT INTO system_settings (item, value) VALUES ('golem15_feedback_settings', ?)`, value).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, false); err != nil {
t.Fatal(err)
}
s, err := models.Instance(ctx, gdb)
if err != nil {
t.Fatal(err)
}
if !s.Enabled || s.AllowHide || s.WidgetKey != "wk_FromWinter0000000000000000000000" || *s.Position != "middle-left" ||
*s.AllowedOrigins != "https://example.com\r\nexample.org" || *s.ColorPrimary != "#111827" || s.ColorAccent != nil ||
*s.LabelTitlePL != "Napisz do nas" || *s.LabelTitleEN != "Write to us" || *s.LabelSuccessEN != "Thanks for the feedback!" ||
*s.G15OfficeTaskStatus != "" || *s.G15OfficeTaskPriority != "high" {
t.Fatalf("imported %+v", s)
}
if err := console.Import(ctx, gdb, false); !errors.Is(err, console.ErrSettingsCustomised) {
t.Fatalf("second import: %v", err)
}
if err := gdb.Exec(`UPDATE system_settings SET value = '{"position":"bottom-left"}'`).Error; err != nil {
t.Fatal(err)
}
if err := console.Import(ctx, gdb, true); err != nil {
t.Fatalf("forced import: %v", err)
}
if s, _ := models.Instance(ctx, gdb); *s.Position != "bottom-left" || s.WidgetKey != "wk_FromWinter0000000000000000000000" {
t.Fatalf("forced import kept %+v", s)
}
}
// TestFeedbackAdminSchemas: the settings form (cabana field types only,
// widget key read-only) and the read-only submissions list compile when
// the admin boots, with the permission, the navigation and the settings
// screen registered, and the settings save through cabana with a
// generated widget key.
func TestFeedbackAdminSchemas(t *testing.T) {
h, app, gdb := newHandler(t)
_ = h
p := &Plugin{}
ctls := p.AdminControllers()
if len(ctls) != 1 || ctls[0].ID() != "golem15.feedback.submissions" {
t.Fatalf("controllers %v", ctls)
}
list, err := cabana.CompileList("golem15.feedback", ctls[0], p.AdminFS())
if err != nil {
t.Fatal(err)
}
if len(list.ToolbarButtons) != 0 || len(list.BulkActions) != 0 {
t.Fatalf("the submissions list is not read-only: %+v %+v", list.ToolbarButtons, list.BulkActions)
}
for _, c := range list.Columns {
if c.Type != "text" && c.Type != "datetime" && c.Type != "" {
t.Fatalf("column %s renders as %s", c.Key, c.Type)
}
}
if _, err := fs.Stat(p.AdminFS(), "controllers/submissions/config_form.yaml"); err == nil {
t.Fatal("the submissions controller has a form")
}
raw, err := fs.ReadFile(p.AdminFS(), "models/settings/fields.yaml")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "colorpicker") || strings.Contains(string(raw), "readOnly") {
t.Fatal("the settings form uses a field type cabana does not support")
}
if perms := p.Permissions(); len(perms) != 1 || perms[0].Code != "golem15.feedback.manage_settings" || perms[0].Roles[0] != "developer" {
t.Fatalf("permissions %+v", perms)
}
if nav := p.Navigation(); len(nav) != 1 || nav[0].SideMenu[0].Controller != "golem15.feedback.submissions" {
t.Fatalf("navigation %+v", nav)
}
if st := p.Settings(); len(st) != 1 || st[0].Code != SettingsCode || st[0].Form != "models/settings/fields.yaml" || st[0].Model != `Golem15\Feedback\Models\Settings` {
t.Fatalf("settings %+v", st)
}
// The admin compiled every schema when the router was assembled; the
// settings screen then creates the row with a widget key.
plugins, err := party.Activate(backpack.New(app.Config), pgtest.PluginIDs)
if err != nil {
t.Fatal(err)
}
if _, err := cabana.Activate(app, plugins); err != nil {
t.Fatalf("admin boot: %v", err)
}
if err := gdb.Exec(`DELETE FROM golem15_feedback_settings`).Error; err != nil {
t.Fatal(err)
}
// cabana's settings save creates the row as id 1 from the form's
// defaults, without a key.
s := &models.Settings{ID: models.SettingsID}
if err := gdb.Create(s).Error; err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(s.WidgetKey, "wk_") {
t.Fatalf("a row created without a key got %q", s.WidgetKey)
}
s.WidgetKey = ""
if err := gdb.Save(s).Error; err != nil || !strings.HasPrefix(s.WidgetKey, "wk_") {
t.Fatalf("an emptied key was saved as %q (%v)", s.WidgetKey, err)
}
}

54
jobs.go Normal file
View File

@@ -0,0 +1,54 @@
package feedback
import (
"context"
"errors"
"log/slog"
"git.golem15.com/golem15/sm-feedback-plugin/classes"
"git.golem15.com/golem15/summercms/modules/conga"
"git.golem15.com/golem15/summercms/modules/pact"
"gocloud.dev/blob"
"gorm.io/gorm"
)
var _ pact.HasJobs = (*Plugin)(nil)
// Jobs registers the G15Office sync worker on the feedback queue, with
// SyncFeedbackToG15Office::$tries attempts (River's backoff replaces PHP's
// fixed 30 s between them).
func (p *Plugin) Jobs() []pact.Job {
return []pact.Job{
conga.Job(p.syncG15Office, conga.OnQueue(classes.SyncG15OfficeQueue), conga.MaxAttempts(3)),
}
}
// syncG15Office resolves the app services per run (Boot runs before serve
// publishes the database), files the submission and completes its
// summer_jobs row. The submission's content, the token and the URLs are
// never logged; only the submission id is.
func (p *Plugin) syncG15Office(ctx context.Context, args classes.SyncG15OfficeArgs) error {
if p.app == nil {
return errors.New("feedback: app is not booted")
}
gdb, ok := p.app.Lookup[*gorm.DB]()
if !ok || gdb == nil {
return errors.New("feedback: database is not published")
}
bucket, _ := p.app.Lookup[*blob.Bucket]()
err := classes.SyncG15Office(ctx, classes.SyncDeps{
DB: gdb,
Bucket: bucket,
Client: classes.NewG15OfficeClient(p.app.Config),
}, args)
if err != nil {
slog.Default().Warn("golem15.feedback: filing a submission in G15Office failed", slog.Uint64("submission_id", uint64(args.SubmissionID)))
return err
}
if id, ok := conga.JobID(ctx); ok {
if m, err := conga.From(p.app); err == nil {
return m.CompleteJob(ctx, id, nil)
}
}
return nil
}

108
models/settings/fields.yaml Normal file
View File

@@ -0,0 +1,108 @@
# The WinterCMS settings form with cabana's field types only: the colors
# are text fields and the widget key is made read-only through its input
# attributes. Defaults are initSettingsData.
fields:
enabled:
tab: golem15.feedback::lang.settings.tab_general
label: golem15.feedback::lang.settings.enabled
type: switch
default: 1
span: full
widget_key:
tab: golem15.feedback::lang.settings.tab_general
label: golem15.feedback::lang.settings.widget_key
comment: golem15.feedback::lang.settings.widget_key_comment
type: text
span: left
attributes:
readonly: true
allow_hide:
tab: golem15.feedback::lang.settings.tab_general
label: golem15.feedback::lang.settings.allow_hide
comment: golem15.feedback::lang.settings.allow_hide_comment
type: switch
default: 0
span: full
position:
tab: golem15.feedback::lang.settings.tab_general
label: golem15.feedback::lang.settings.position
type: dropdown
default: bottom-right
options:
bottom-right: golem15.feedback::lang.settings.position_bottom_right
bottom-left: golem15.feedback::lang.settings.position_bottom_left
middle-right: golem15.feedback::lang.settings.position_middle_right
middle-left: golem15.feedback::lang.settings.position_middle_left
span: right
allowed_origins:
tab: golem15.feedback::lang.settings.tab_general
label: golem15.feedback::lang.settings.allowed_origins
comment: golem15.feedback::lang.settings.allowed_origins_comment
type: textarea
size: small
span: full
color_primary:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.color_primary
comment: golem15.feedback::lang.settings.color_primary_comment
type: text
span: left
color_accent:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.color_accent
comment: golem15.feedback::lang.settings.color_accent_comment
type: text
span: right
label_title_pl:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_title_pl
type: text
default: Zgłoś opinię
span: left
label_title_en:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_title_en
type: text
default: Send feedback
span: right
label_placeholder_pl:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_placeholder_pl
type: text
default: Co poszło nie tak?
span: left
label_placeholder_en:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_placeholder_en
type: text
default: What went wrong?
span: right
label_success_pl:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_success_pl
type: text
default: Dzięki za zgłoszenie!
span: left
label_success_en:
tab: golem15.feedback::lang.settings.tab_appearance
label: golem15.feedback::lang.settings.label_success_en
type: text
default: Thanks for the feedback!
span: right
g15office_task_status:
tab: golem15.feedback::lang.settings.tab_g15office
label: golem15.feedback::lang.settings.g15office_task_status
comment: golem15.feedback::lang.settings.g15office_task_status_comment
type: text
span: right
g15office_task_priority:
tab: golem15.feedback::lang.settings.tab_g15office
label: golem15.feedback::lang.settings.g15office_task_priority
type: dropdown
default: normal
options:
low: golem15.feedback::lang.settings.priority_low
normal: golem15.feedback::lang.settings.priority_normal
high: golem15.feedback::lang.settings.priority_high
urgent: golem15.feedback::lang.settings.priority_urgent
span: full

View File

@@ -0,0 +1,32 @@
# Read-only list of the widget's submissions. Every column is plain text:
# the message and the other visitor-supplied values are never rendered as
# HTML.
columns:
id:
label: golem15.feedback::lang.submissions.id
sortable: true
type:
label: golem15.feedback::lang.submissions.type
type: text
status:
label: golem15.feedback::lang.submissions.status
type: text
email:
label: golem15.feedback::lang.submissions.email
type: text
searchable: true
page_url:
label: golem15.feedback::lang.submissions.page_url
type: text
searchable: true
message:
label: golem15.feedback::lang.submissions.message
type: text
searchable: true
g15office_task_id:
label: golem15.feedback::lang.submissions.g15office_task_id
type: text
created_at:
label: golem15.feedback::lang.submissions.created_at
type: datetime
sortable: true

View File

@@ -14,10 +14,12 @@ import (
"net/http"
"time"
"git.golem15.com/golem15/sm-feedback-plugin/console"
"git.golem15.com/golem15/sm-feedback-plugin/models"
"git.golem15.com/golem15/sm-feedback-plugin/updates"
userclasses "git.golem15.com/golem15/sm-user-plugin/classes"
"git.golem15.com/golem15/summercms/modules/backpack"
"git.golem15.com/golem15/summercms/modules/bonfire"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
"git.golem15.com/golem15/summercms/modules/surf"
@@ -32,6 +34,7 @@ var (
_ pact.HasMigrations = (*Plugin)(nil)
_ pact.HasModels = (*Plugin)(nil)
_ pact.HasRoutes = (*Plugin)(nil)
_ pact.HasCommands = (*Plugin)(nil)
_ surf.BucketProvider = (*Plugin)(nil)
)
@@ -102,6 +105,11 @@ func (p *Plugin) Migrations() []*gormigrate.Migration { return updates.All() }
// Models is Submission, UserPreference and Settings.
func (p *Plugin) Models() []any { return models.All() }
// Commands is feedback:import-settings.
func (p *Plugin) Commands() []bonfire.Command {
return []bonfire.Command{console.ImportSettings(p.app)}
}
// Buckets are the widget routes' limits by client IP (behind the trusted
// proxies): 60 config reads and 10 submissions a minute.
func (p *Plugin) Buckets() map[string]surf.Bucket {

View File

@@ -20,5 +20,7 @@ func (p *Plugin) Routes(r pact.Router) error {
r.Group("/_feedback/api/v1", surf.Use("jwt.auth"), func(g pact.Router) {
g.Put("/me/hidden", api.MeHidden(p.app))
})
// The widget script, at the path of the WinterCMS plugin's file.
r.Get(EmbedJSPath, EmbedJS())
return nil
}